diff --git a/.gitignore b/.gitignore index 441503fb2..cea92d6a9 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ planify*txt .flatpak .buildconfig .DS_Store +.superpowers/ diff --git a/core/QuickAddCore.vala b/core/QuickAddCore.vala index 1b0466a76..7783acaf5 100644 --- a/core/QuickAddCore.vala +++ b/core/QuickAddCore.vala @@ -39,6 +39,7 @@ public class Layouts.QuickAddCore : Adw.Bin { private Gtk.Image added_image; private Gtk.Stack main_stack; private Gtk.ToggleButton create_more_button; + private Gtk.ToggleButton ai_parse_button; private Gtk.Revealer info_revealer; private Gtk.Overlay animation_overlay; private Gtk.Fixed animation_container; @@ -342,7 +343,16 @@ public class Layouts.QuickAddCore : Adw.Bin { halign = END }; + ai_parse_button = new Gtk.ToggleButton () { + icon_name = "starred-symbolic", + tooltip_text = _("Parse with Claude AI (Ctrl+Shift+Enter)"), + css_classes = { "flat" }, + visible = false, + valign = Gtk.Align.CENTER + }; + submit_cancel_grid.append (create_more_button); + submit_cancel_grid.append (ai_parse_button); submit_cancel_grid.append (submit_button); project_picker_button = new Widgets.ProjectPicker.ProjectPickerButton () { @@ -751,13 +761,30 @@ public class Layouts.QuickAddCore : Adw.Bin { return true; })); + var ai_parse_shortcut = new Gtk.Shortcut ( + Gtk.ShortcutTrigger.parse_string ("Return"), + new Gtk.CallbackAction (() => { + if (!is_loading && Services.AI.Claude.get_default ().is_configured ()) { + ai_parse_button.active = true; + ai_parse_item (); + } + return true; + }) + ); + shortcut_controller = new Gtk.ShortcutController (); shortcut_controller.add_shortcut (open_label_shortcut); shortcut_controller.add_shortcut (open_reminder_shortcut); shortcut_controller.add_shortcut (toggle_keep_adding_shortcut); shortcut_controller.add_shortcut (open_schedule_shortcut); + shortcut_controller.add_shortcut (ai_parse_shortcut); add_controller (shortcut_controller); + ai_parse_button.visible = Services.AI.Claude.get_default ().is_configured (); + signal_map[Services.AI.Claude.get_default ().status_changed.connect (() => { + ai_parse_button.visible = Services.AI.Claude.get_default ().is_configured (); + })] = Services.AI.Claude.get_default (); + destroy_controller = new Gtk.EventControllerKey (); add_controller (destroy_controller); signal_map[destroy_controller.key_released.connect ((keyval, keycode, state) => { @@ -782,6 +809,11 @@ public class Layouts.QuickAddCore : Adw.Bin { return; } + if (ai_parse_button.active && content_entry.get_text ().strip ().length > 0) { + ai_parse_item (); + return; + } + info_revealer.reveal_child = false; content_entry.remove_css_class ("error"); @@ -846,6 +878,45 @@ public class Layouts.QuickAddCore : Adw.Bin { } } + private void ai_parse_item () { + info_revealer.reveal_child = false; + content_entry.remove_css_class ("error"); + + is_loading = true; + var parser = new Services.AI.TaskParser (); + parser.parse_natural_language.begin (content_entry.get_text (), (obj, res) => { + Services.AI.ParsedItem? parsed = parser.parse_natural_language.end (res); + is_loading = false; + ai_parse_button.active = false; + + if (parsed == null) { + Services.LogService.get_default ().error ("QuickAdd", "AI parse failed"); + content_entry.add_css_class ("error"); + Timeout.add (info_revealer.transition_duration, () => { + info_revealer.reveal_child = true; + return GLib.Source.REMOVE; + }); + return; + } + + content_entry.set_text (parsed.title); + + if (parsed.due_date != null) { + var due = new Objects.DueDate (); + if (parsed.due_time != null) { + due.date = parsed.due_date + "T" + parsed.due_time + ":00"; + } else { + due.date = parsed.due_date; + } + item.due = due; + schedule_button.update_from_item (item); + } + + item.priority = parsed.priority; + priority_button.update_from_item (item); + }); + } + private void _add_item (Objects.Item item) { add_item_db (item, reminder_button.reminders ()); diff --git a/core/Services/AI/Claude.vala b/core/Services/AI/Claude.vala new file mode 100644 index 000000000..323b275dc --- /dev/null +++ b/core/Services/AI/Claude.vala @@ -0,0 +1,167 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +public class Services.AI.Claude : GLib.Object { + private const string API_URL = "https://api.anthropic.com/v1/messages"; + private const string ANTHROPIC_VERSION = "2023-06-01"; + private const string SECRET_SERVICE = "io.github.alainm23.planify.claude"; + private const string SECRET_KEY_ATTR = "service"; + private const string SECRET_KEY_VALUE = "planify-claude-apikey"; + + private static Secret.Schema SCHEMA; + + private Soup.Session session; + + public enum Status { + NOT_CONFIGURED, + CONFIGURED, + ERROR + } + + public Status status { get; private set; default = Status.NOT_CONFIGURED; } + public string last_error { get; private set; default = ""; } + + public signal void status_changed (); + + private static Claude? _instance; + public static Claude get_default () { + if (_instance == null) _instance = new Claude (); + return _instance; + } + + static construct { + SCHEMA = new Secret.Schema ( + SECRET_SERVICE, + Secret.SchemaFlags.NONE, + SECRET_KEY_ATTR, Secret.SchemaAttributeType.STRING + ); + } + + public Claude () { + session = new Soup.Session (); + update_status (); + } + + public string? resolve_api_key () { + string? env_key = GLib.Environment.get_variable ("ANTHROPIC_API_KEY"); + if (env_key != null && env_key.length > 0) return env_key; + + try { + return Secret.password_lookup_sync (SCHEMA, null, SECRET_KEY_ATTR, SECRET_KEY_VALUE); + } catch (Error e) { + return null; + } + } + + public bool is_configured () { + return resolve_api_key () != null; + } + + public void store_api_key (string api_key) throws Error { + Secret.password_store_sync (SCHEMA, Secret.COLLECTION_DEFAULT, + "Planify Claude API Key", api_key, null, + SECRET_KEY_ATTR, SECRET_KEY_VALUE); + update_status (); + } + + public void clear_api_key () throws Error { + Secret.password_clear_sync (SCHEMA, null, SECRET_KEY_ATTR, SECRET_KEY_VALUE); + update_status (); + } + + public string get_model () { + return Services.Settings.get_default ().settings.get_string ("claude-model"); + } + + private void update_status () { + status = is_configured () ? Status.CONFIGURED : Status.NOT_CONFIGURED; + status_changed (); + } + + public async string? send_request (string prompt) { + string? api_key = resolve_api_key (); + if (api_key == null) { + status = Status.NOT_CONFIGURED; + status_changed (); + return null; + } + + var builder = new Json.Builder (); + builder.begin_object (); + builder.set_member_name ("model"); + builder.add_string_value (get_model ()); + builder.set_member_name ("max_tokens"); + builder.add_int_value (2048); + builder.set_member_name ("messages"); + builder.begin_array (); + builder.begin_object (); + builder.set_member_name ("role"); + builder.add_string_value ("user"); + builder.set_member_name ("content"); + builder.add_string_value (prompt); + builder.end_object (); + builder.end_array (); + builder.end_object (); + + var generator = new Json.Generator (); + generator.set_root (builder.get_root ()); + string body = generator.to_data (null); + + var message = new Soup.Message ("POST", API_URL); + message.request_headers.append ("x-api-key", api_key); + message.request_headers.append ("anthropic-version", ANTHROPIC_VERSION); + message.set_request_body_from_bytes ("application/json", new GLib.Bytes (body.data)); + + try { + GLib.Bytes stream = yield session.send_and_read_async ( + message, GLib.Priority.DEFAULT, null); + string response_body = (string) stream.get_data (); + + var parser = new Json.Parser (); + parser.load_from_data (response_body); + var root = parser.get_root ().get_object (); + + if (root.has_member ("error")) { + last_error = root.get_object_member ("error").get_string_member ("message"); + status = Status.ERROR; + status_changed (); + return null; + } + + last_error = ""; + status = Status.CONFIGURED; + status_changed (); + + return root + .get_array_member ("content") + .get_object_element (0) + .get_string_member ("text"); + } catch (Error e) { + last_error = e.message; + status = Status.ERROR; + status_changed (); + return null; + } + } + + public async bool ping () { + string? result = yield send_request ("Reply with only the word: OK"); + return result != null; + } +} diff --git a/core/Services/AI/ImportMapper.vala b/core/Services/AI/ImportMapper.vala new file mode 100644 index 000000000..52ba419c8 --- /dev/null +++ b/core/Services/AI/ImportMapper.vala @@ -0,0 +1,158 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +public struct Services.AI.MappedItem { + public string title; + public string? due_date; + public int priority; + public string notes; +} + +public struct Services.AI.MappedSection { + public string name; + public Gee.ArrayList items; +} + +public struct Services.AI.MappedProject { + public string name; + public Gee.ArrayList sections; +} + +public struct Services.AI.AmbiguityFlag { + public string line; + public string reason; + public string suggested_fix; +} + +public class Services.AI.ImportResult : GLib.Object { + public Gee.ArrayList projects; + public Gee.ArrayList ambiguities; + + public ImportResult () { + projects = new Gee.ArrayList (); + ambiguities = new Gee.ArrayList (); + } +} + +public class Services.AI.ImportMapper : GLib.Object { + private const int CHUNK_SIZE_BYTES = 50000; + + public async ImportResult? map_file (string content, string mime_hint) { + if (content.length <= CHUNK_SIZE_BYTES) { + return yield map_chunk (content, mime_hint); + } + + var merged = new Services.AI.ImportResult (); + int offset = 0; + while (offset < content.length) { + int end = int.min (offset + CHUNK_SIZE_BYTES, (int) content.length); + // Align end to a UTF-8 character boundary + if (end < (int) content.length) { + while (end > offset && (content[end] & 0xC0) == 0x80) { + end--; + } + } + string chunk = content.slice (offset, end); + Services.AI.ImportResult? chunk_result = yield map_chunk (chunk, mime_hint); + if (chunk_result != null) { + foreach (var p in chunk_result.projects) merged.projects.add (p); + foreach (var a in chunk_result.ambiguities) merged.ambiguities.add (a); + } + offset = end; + } + if (merged.projects.is_empty && merged.ambiguities.is_empty) { + return null; + } + return merged; + } + + private async ImportResult? map_chunk (string content, string mime_hint) { + string prompt = Services.AI.Prompts.map_import_file (content, mime_hint); + string? response = yield Services.AI.Claude.get_default ().send_request (prompt); + if (response == null) return null; + + try { + string clean = extract_json_object (response); + var parser = new Json.Parser (); + parser.load_from_data (clean); + var root = parser.get_root ().get_object (); + + var result = new Services.AI.ImportResult (); + + if (root.has_member ("projects")) { + var projects_arr = root.get_array_member ("projects"); + projects_arr.foreach_element ((arr, idx, node) => { + var proj_obj = node.get_object (); + Services.AI.MappedProject project = Services.AI.MappedProject (); + project.name = proj_obj.has_member ("name") ? proj_obj.get_string_member ("name") : _("Untitled Project"); + project.sections = new Gee.ArrayList (); + + if (proj_obj.has_member ("sections")) { + proj_obj.get_array_member ("sections").foreach_element ((sarr, sidx, snode) => { + var sec_obj = snode.get_object (); + Services.AI.MappedSection section = Services.AI.MappedSection (); + section.name = sec_obj.has_member ("name") ? sec_obj.get_string_member ("name") : ""; + section.items = new Gee.ArrayList (); + + if (sec_obj.has_member ("items")) { + sec_obj.get_array_member ("items").foreach_element ((iarr, iidx, inode) => { + var item_obj = inode.get_object (); + Services.AI.MappedItem item = Services.AI.MappedItem (); + item.title = item_obj.has_member ("title") ? item_obj.get_string_member ("title") : _("Untitled Task"); + item.due_date = (item_obj.has_member ("due_date") && + item_obj.get_member ("due_date").get_node_type () != Json.NodeType.NULL) + ? item_obj.get_string_member ("due_date") : null; + item.priority = item_obj.has_member ("priority") + ? (int) item_obj.get_int_member ("priority") : Constants.PRIORITY_4; + item.notes = item_obj.has_member ("notes") ? item_obj.get_string_member ("notes") : ""; + section.items.add (item); + }); + } + project.sections.add (section); + }); + } + result.projects.add (project); + }); + } + + if (root.has_member ("ambiguities")) { + root.get_array_member ("ambiguities").foreach_element ((arr, idx, node) => { + var amb_obj = node.get_object (); + Services.AI.AmbiguityFlag flag = Services.AI.AmbiguityFlag (); + flag.line = amb_obj.has_member ("line") ? amb_obj.get_string_member ("line") : ""; + flag.reason = amb_obj.has_member ("reason") ? amb_obj.get_string_member ("reason") : ""; + flag.suggested_fix = amb_obj.has_member ("suggested_fix") ? amb_obj.get_string_member ("suggested_fix") : ""; + result.ambiguities.add (flag); + }); + } + + return result; + } catch (Error e) { + Services.LogService.get_default ().error ("ImportMapper", "Failed to parse import: " + e.message); + return null; + } + } + + private string extract_json_object (string text) { + int start = text.index_of ("{"); + int end = text.last_index_of ("}"); + if (start >= 0 && end > start) return text.slice (start, end + 1); + return text; + } +} diff --git a/core/Services/AI/Prompts.vala b/core/Services/AI/Prompts.vala new file mode 100644 index 000000000..c78c0c9a3 --- /dev/null +++ b/core/Services/AI/Prompts.vala @@ -0,0 +1,90 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +public class Services.AI.Prompts : GLib.Object { + + public static string parse_natural_language (string input) { + return ( + "You are a task parser. Extract task details from the user's natural-language input.\n" + + "Return ONLY a valid JSON object with these fields (omit fields you cannot determine):\n" + + "{\n" + + " \"title\": \"task title as a clean imperative phrase\",\n" + + " \"due_date\": \"YYYY-MM-DD or null\",\n" + + " \"due_time\": \"HH:MM (24h) or null\",\n" + + " \"priority\": 1,\n" + + " \"labels\": [\"label1\"]\n" + + "}\n" + + "Priority scale: 4=urgent, 3=high, 2=medium, 1=none.\n" + + "Today is " + new GLib.DateTime.now_local ().format ("%Y-%m-%d") + ".\n" + + "User input: " + input + ); + } + + public static string generate_subtasks (string task_title, string task_description) { + string desc_part = task_description != "" ? + "\nDescription: " + task_description : ""; + return ( + "You are a task breakdown assistant. Break the following task into concrete, actionable subtasks.\n" + + "Return ONLY a valid JSON array of strings, each being a short subtask title.\n" + + "Maximum 8 subtasks. No numbering. No empty strings.\n" + + "Task: " + task_title + desc_part + ); + } + + public static string schedule_items (string items_json) { + return ( + "You are a productivity assistant. Suggest due dates and priorities for these tasks.\n" + + "Today is " + new GLib.DateTime.now_local ().format ("%Y-%m-%d") + ".\n" + + "Return ONLY a valid JSON array:\n" + + "[{\"id\": \"item_id\", \"suggested_due_date\": \"YYYY-MM-DD or null\", " + + "\"suggested_priority\": 4, \"reason\": \"brief reason\"}]\n" + + "Priority scale: 4=urgent, 3=high, 2=medium, 1=none.\n" + + "Tasks (JSON): " + items_json + ); + } + + public static string map_import_file (string content, string mime_hint) { + return ( + "You are a task import assistant. Parse this " + mime_hint + " file into tasks.\n" + + "Return ONLY a valid JSON object:\n" + + "{\n" + + " \"projects\": [\n" + + " {\n" + + " \"name\": \"project name\",\n" + + " \"sections\": [\n" + + " {\n" + + " \"name\": \"section name or empty string for no section\",\n" + + " \"items\": [\n" + + " {\"title\": \"task\", \"due_date\": \"YYYY-MM-DD or null\", " + + "\"priority\": 4, \"notes\": \"description or empty string\"}\n" + + " ]\n" + + " }\n" + + " ]\n" + + " }\n" + + " ],\n" + + " \"ambiguities\": [\n" + + " {\"line\": \"original text\", \"reason\": \"why unclear\", " + + "\"suggested_fix\": \"suggestion\"}\n" + + " ]\n" + + "}\n" + + "Priority scale: 4=urgent, 3=high, 2=medium, 1=none.\n" + + "File content:\n" + content + ); + } +} diff --git a/core/Services/AI/Scheduler.vala b/core/Services/AI/Scheduler.vala new file mode 100644 index 000000000..66d4d84aa --- /dev/null +++ b/core/Services/AI/Scheduler.vala @@ -0,0 +1,97 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +public struct Services.AI.ScheduleSuggestion { + public string item_id; + public string? suggested_due_date; + public int suggested_priority; + public string reason; +} + +public class Services.AI.Scheduler : GLib.Object { + + public async Gee.ArrayList? suggest ( + Gee.ArrayList items) + { + if (items.is_empty) return null; + + string items_json = build_items_json (items); + string prompt = Services.AI.Prompts.schedule_items (items_json); + string? response = yield Services.AI.Claude.get_default ().send_request (prompt); + if (response == null) return null; + + try { + string clean = extract_json_array (response); + var parser = new Json.Parser (); + parser.load_from_data (clean); + var arr = parser.get_root ().get_array (); + + var suggestions = new Gee.ArrayList (); + arr.foreach_element ((a, idx, node) => { + var obj = node.get_object (); + Services.AI.ScheduleSuggestion s = Services.AI.ScheduleSuggestion (); + s.item_id = obj.has_member ("id") ? obj.get_string_member ("id") : ""; + s.suggested_due_date = (obj.has_member ("suggested_due_date") && + obj.get_member ("suggested_due_date").get_node_type () != Json.NodeType.NULL) + ? obj.get_string_member ("suggested_due_date") : null; + s.suggested_priority = obj.has_member ("suggested_priority") + ? (int) obj.get_int_member ("suggested_priority") : Constants.PRIORITY_4; + s.reason = obj.has_member ("reason") ? obj.get_string_member ("reason") : ""; + suggestions.add (s); + }); + + return suggestions; + } catch (Error e) { + Services.LogService.get_default ().error ("Scheduler", "Failed to parse suggestions: " + e.message); + return null; + } + } + + private string build_items_json (Gee.ArrayList items) { + var builder = new Json.Builder (); + builder.begin_array (); + foreach (var item in items) { + builder.begin_object (); + builder.set_member_name ("id"); + builder.add_string_value (item.id); + builder.set_member_name ("title"); + builder.add_string_value (item.content); + builder.set_member_name ("due_date"); + if (item.due.date != null && item.due.date != "") { + builder.add_string_value (item.due.date); + } else { + builder.add_null_value (); + } + builder.set_member_name ("priority"); + builder.add_int_value (item.priority); + builder.end_object (); + } + builder.end_array (); + var gen = new Json.Generator (); + gen.set_root (builder.get_root ()); + return gen.to_data (null); + } + + private string extract_json_array (string text) { + int start = text.index_of ("["); + int end = text.last_index_of ("]"); + if (start >= 0 && end > start) return text.slice (start, end + 1); + return text; + } +} diff --git a/core/Services/AI/TaskParser.vala b/core/Services/AI/TaskParser.vala new file mode 100644 index 000000000..88da47ae7 --- /dev/null +++ b/core/Services/AI/TaskParser.vala @@ -0,0 +1,105 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +public struct Services.AI.ParsedItem { + public string title; + public string? due_date; + public string? due_time; + public int priority; + public string[] labels; +} + +public class Services.AI.TaskParser : GLib.Object { + + public async ParsedItem? parse_natural_language (string input) { + string prompt = Services.AI.Prompts.parse_natural_language (input); + string? response = yield Services.AI.Claude.get_default ().send_request (prompt); + if (response == null) return null; + + try { + string clean = extract_json_object (response); + var parser = new Json.Parser (); + parser.load_from_data (clean); + var obj = parser.get_root ().get_object (); + + ParsedItem result = ParsedItem (); + result.title = obj.has_member ("title") ? obj.get_string_member ("title") : input; + + result.due_date = (obj.has_member ("due_date") && obj.get_member ("due_date").get_node_type () != Json.NodeType.NULL) + ? obj.get_string_member ("due_date") : null; + + result.due_time = (obj.has_member ("due_time") && obj.get_member ("due_time").get_node_type () != Json.NodeType.NULL) + ? obj.get_string_member ("due_time") : null; + + result.priority = obj.has_member ("priority") + ? (int) obj.get_int_member ("priority") : Constants.PRIORITY_4; + + if (obj.has_member ("labels") && obj.get_member ("labels").get_node_type () == Json.NodeType.ARRAY) { + var arr = obj.get_array_member ("labels"); + result.labels = new string[arr.get_length ()]; + for (uint i = 0; i < arr.get_length (); i++) { + result.labels[i] = arr.get_string_element (i); + } + } else { + result.labels = new string[0]; + } + + return result; + } catch (Error e) { + Services.LogService.get_default ().error ("TaskParser", "Failed to parse response: " + e.message); + return null; + } + } + + public async string[]? generate_subtasks (Objects.Item parent) { + string prompt = Services.AI.Prompts.generate_subtasks (parent.content, parent.description); + string? response = yield Services.AI.Claude.get_default ().send_request (prompt); + if (response == null) return null; + + try { + string clean = extract_json_array (response); + var parser = new Json.Parser (); + parser.load_from_data (clean); + var arr = parser.get_root ().get_array (); + + string[] subtasks = new string[arr.get_length ()]; + for (uint i = 0; i < arr.get_length (); i++) { + subtasks[i] = arr.get_string_element (i); + } + return subtasks; + } catch (Error e) { + Services.LogService.get_default ().error ("TaskParser", "Failed to parse subtasks: " + e.message); + return null; + } + } + + private string extract_json_object (string text) { + int start = text.index_of ("{"); + int end = text.last_index_of ("}"); + if (start >= 0 && end > start) return text.slice (start, end + 1); + return text; + } + + private string extract_json_array (string text) { + int start = text.index_of ("["); + int end = text.last_index_of ("]"); + if (start >= 0 && end > start) return text.slice (start, end + 1); + return text; + } +} diff --git a/core/meson.build b/core/meson.build index 725165cc4..0e5d2d2e4 100644 --- a/core/meson.build +++ b/core/meson.build @@ -25,6 +25,12 @@ core_files = files( 'Services/CalDAV/WebDAVClient.vala', 'Services/CalDAV/Providers/Nextcloud.vala', + 'Services/AI/Claude.vala', + 'Services/AI/Prompts.vala', + 'Services/AI/TaskParser.vala', + 'Services/AI/ImportMapper.vala', + 'Services/AI/Scheduler.vala', + 'Layouts/HeaderItem.vala', 'Widgets/LoadingButton.vala', diff --git a/data/io.github.alainm23.planify.gschema.xml b/data/io.github.alainm23.planify.gschema.xml index 42dd632c4..edb9f75dc 100644 --- a/data/io.github.alainm23.planify.gschema.xml +++ b/data/io.github.alainm23.planify.gschema.xml @@ -388,5 +388,11 @@ Last automatic backup date ISO date of the last automatic backup, used to detect missed backups on startup + + + "claude-sonnet-4-6" + Claude AI model to use + Model ID used for Claude AI features. Options: claude-haiku-4-5-20251001, claude-sonnet-4-6, claude-opus-4-8 + diff --git a/docs/superpowers/plans/2026-05-29-claude-ai-integration.md b/docs/superpowers/plans/2026-05-29-claude-ai-integration.md new file mode 100644 index 000000000..2cce4d3ef --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-claude-ai-integration.md @@ -0,0 +1,1997 @@ +# Claude AI Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Claude AI features to Planify: natural language task creation, subtask generation, smart scheduling/prioritization, and AI-assisted file import. + +**Architecture:** A layered `core/Services/AI/` service folder with `Claude.vala` (HTTP only), `Prompts.vala` (prompt templates only), and three feature files (`TaskParser`, `ImportMapper`, `Scheduler`). UI surfaces: parse button in quick-add, sparkle button on task rows, `Adw.BottomSheet` assistant panel, and `Adw.Dialog` import dialog. All feature files return typed Vala structs; JSON parsing lives exclusively in `Claude.vala`. + +**Tech Stack:** Vala, GTK4, libadwaita-1, libsoup-3.0 (HTTP), json-glib-1.0 (JSON), libsecret-1 (keyring storage), GLib.Settings (model preference) + +--- + +## File Map + +| Action | Path | Purpose | +|--------|------|---------| +| Create | `core/Services/AI/Claude.vala` | HTTP client, API key resolution, status signal | +| Create | `core/Services/AI/Prompts.vala` | Static prompt template methods | +| Create | `core/Services/AI/TaskParser.vala` | NL → Item; subtask generation | +| Create | `core/Services/AI/ImportMapper.vala` | File content → Projects/Sections/Items + ambiguity flags | +| Create | `core/Services/AI/Scheduler.vala` | Item list → suggested due dates + priority order | +| Create | `src/Widgets/ClaudeStatusBadge.vala` | Shared green/amber/red status dot widget | +| Create | `src/Views/AI/AssistantPanel.vala` | Adw.BottomSheet with Prioritize + Schedule actions | +| Create | `test/test-ai-unit.vala` | GLib.Test for Prompts + Claude API key resolution | +| Modify | `core/meson.build` | Add AI service files to `core_files` | +| Modify | `src/meson.build` | Add ClaudeStatusBadge + AssistantPanel | +| Modify | `test/meson.build` | Add test-ai-unit executable | +| Modify | `data/io.github.alainm23.planify.gschema.xml` | Add `claude-model` string key | +| Modify | `core/QuickAddCore.vala` | Add parse toggle button + Ctrl+Shift+Enter shortcut | +| Modify | `src/Layouts/ItemRow.vala` | Add sparkle button (hover reveal) for subtask generation | +| Modify | `src/Layouts/Sidebar.vala` | Add wand button to trigger AssistantPanel | +| Modify | `src/Dialogs/Preferences.vala` | Add Claude preferences section | + +--- + +## Task 1: Build wiring — meson.build + +**Files:** +- Modify: `core/meson.build` +- Modify: `src/meson.build` +- Modify: `test/meson.build` + +- [ ] **Step 1: Add AI service files to core_files in `core/meson.build`** + +In `core/meson.build`, after the `'Services/CalDAV/Providers/Nextcloud.vala',` line, add: + +``` + 'Services/AI/Claude.vala', + 'Services/AI/Prompts.vala', + 'Services/AI/TaskParser.vala', + 'Services/AI/ImportMapper.vala', + 'Services/AI/Scheduler.vala', +``` + +- [ ] **Step 2: Add UI files to src meson.build** + +In `src/meson.build`, find the list of source files and add: + +``` + 'Widgets/ClaudeStatusBadge.vala', + 'Views/AI/AssistantPanel.vala', +``` + +- [ ] **Step 3: Add test target to `test/meson.build`** + +Append to `test/meson.build`: + +```meson +test_ai_sources = [ + 'test-ai-unit.vala', +] + +test_ai = executable( + 'test-ai', + test_ai_sources, + dependencies: [ core_dep, glib_dep ], + install: false +) + +test('ai-unit', test_ai, timeout: 30, suite: ['ai-unit']) +``` + +- [ ] **Step 4: Create stub files so meson can configure without errors** + +Create `core/Services/AI/Claude.vala` with: +```vala +public class Services.AI.Claude : GLib.Object {} +``` + +Create `core/Services/AI/Prompts.vala` with: +```vala +public class Services.AI.Prompts : GLib.Object {} +``` + +Create `core/Services/AI/TaskParser.vala` with: +```vala +public class Services.AI.TaskParser : GLib.Object {} +``` + +Create `core/Services/AI/ImportMapper.vala` with: +```vala +public class Services.AI.ImportMapper : GLib.Object {} +``` + +Create `core/Services/AI/Scheduler.vala` with: +```vala +public class Services.AI.Scheduler : GLib.Object {} +``` + +Create `src/Widgets/ClaudeStatusBadge.vala` with: +```vala +public class Widgets.ClaudeStatusBadge : Adw.Bin {} +``` + +Create `src/Views/AI/AssistantPanel.vala` with: +```vala +public class Views.AI.AssistantPanel : Adw.Bin {} +``` + +Create `test/test-ai-unit.vala` with: +```vala +int main (string[] args) { + Test.init (ref args); + return Test.run (); +} +``` + +- [ ] **Step 5: Verify meson configure succeeds** + +```bash +cd /home/jlagman/Projects/planify +meson setup build --wipe 2>&1 | tail -20 +``` + +Expected: `Build targets in project: ...` with no errors. + +- [ ] **Step 6: Commit** + +```bash +git add core/meson.build src/meson.build test/meson.build \ + core/Services/AI/Claude.vala core/Services/AI/Prompts.vala \ + core/Services/AI/TaskParser.vala core/Services/AI/ImportMapper.vala \ + core/Services/AI/Scheduler.vala \ + src/Widgets/ClaudeStatusBadge.vala src/Views/AI/AssistantPanel.vala \ + test/test-ai-unit.vala +git commit -m "build: add Claude AI service stubs and meson wiring" +``` + +--- + +## Task 2: GSettings schema — claude-model key + +**Files:** +- Modify: `data/io.github.alainm23.planify.gschema.xml` + +- [ ] **Step 1: Add `claude-model` key to the schema** + +In `data/io.github.alainm23.planify.gschema.xml`, find the last `` entry and add after it: + +```xml + + "claude-sonnet-4-6" + Claude AI model to use + Model ID used for Claude AI features. Options: claude-haiku-4-5-20251001, claude-sonnet-4-6, claude-opus-4-8 + +``` + +- [ ] **Step 2: Verify schema compiles** + +```bash +cd /home/jlagman/Projects/planify +glib-compile-schemas data/ +``` + +Expected: no output (success). + +- [ ] **Step 3: Commit** + +```bash +git add data/io.github.alainm23.planify.gschema.xml +git commit -m "feat: add claude-model GSettings key" +``` + +--- + +## Task 3: `core/Services/AI/Claude.vala` — HTTP client + +**Files:** +- Create: `core/Services/AI/Claude.vala` +- Modify: `test/test-ai-unit.vala` + +- [ ] **Step 1: Write the failing test for API key resolution** + +Replace `test/test-ai-unit.vala` with: + +```vala +using GLib; + +void test_api_key_env_var_takes_precedence () { + Environment.set_variable ("ANTHROPIC_API_KEY", "test-key-from-env", true); + var claude = new Services.AI.Claude (); + assert_cmpstr (claude.resolve_api_key (), CompareOperator.EQ, "test-key-from-env"); + Environment.unset_variable ("ANTHROPIC_API_KEY"); +} + +void test_api_key_returns_null_when_not_set () { + Environment.unset_variable ("ANTHROPIC_API_KEY"); + var claude = new Services.AI.Claude (); + // libsecret may return null in test environment — that's fine + // we just verify it doesn't crash + string? key = claude.resolve_api_key (); + // key is either null or a previously stored value — both valid + assert (key == null || key.length > 0); +} + +void test_is_not_configured_without_key () { + Environment.unset_variable ("ANTHROPIC_API_KEY"); + var claude = new Services.AI.Claude (); + // Without env var or stored key, should report not configured + // (in CI this will always be not configured) + bool configured = claude.is_configured (); + assert (configured == false || configured == true); // doesn't crash +} + +int main (string[] args) { + Test.init (ref args); + Test.add_func ("/ai/api-key-env-var", test_api_key_env_var_takes_precedence); + Test.add_func ("/ai/api-key-null-when-unset", test_api_key_returns_null_when_not_set); + Test.add_func ("/ai/is-not-configured", test_is_not_configured_without_key); + return Test.run (); +} +``` + +- [ ] **Step 2: Run test to verify it fails (class not defined)** + +```bash +cd /home/jlagman/Projects/planify/build +ninja test-ai 2>&1 | tail -20 +``` + +Expected: compile error — `Services.AI.Claude` not defined. + +- [ ] **Step 3: Implement `core/Services/AI/Claude.vala`** + +```vala +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +public class Services.AI.Claude : GLib.Object { + private const string API_URL = "https://api.anthropic.com/v1/messages"; + private const string ANTHROPIC_VERSION = "2023-06-01"; + private const string SECRET_SERVICE = "io.github.alainm23.planify.claude"; + private const string SECRET_KEY_ATTR = "service"; + private const string SECRET_KEY_VALUE = "planify-claude-apikey"; + + private static Secret.Schema SCHEMA; + + private Soup.Session session; + + public enum Status { + NOT_CONFIGURED, + CONFIGURED, + ERROR + } + + public Status status { get; private set; default = Status.NOT_CONFIGURED; } + public string last_error { get; private set; default = ""; } + + public signal void status_changed (); + + private static Claude? _instance; + public static Claude get_default () { + if (_instance == null) _instance = new Claude (); + return _instance; + } + + static construct { + SCHEMA = new Secret.Schema ( + SECRET_SERVICE, + Secret.SchemaFlags.NONE, + SECRET_KEY_ATTR, Secret.SchemaAttributeType.STRING + ); + } + + public Claude () { + session = new Soup.Session (); + update_status (); + } + + public string? resolve_api_key () { + string? env_key = GLib.Environment.get_variable ("ANTHROPIC_API_KEY"); + if (env_key != null && env_key.length > 0) return env_key; + + try { + return Secret.password_lookup_sync (SCHEMA, null, SECRET_KEY_ATTR, SECRET_KEY_VALUE); + } catch (Error e) { + return null; + } + } + + public bool is_configured () { + return resolve_api_key () != null; + } + + public void store_api_key (string api_key) throws Error { + Secret.password_store_sync (SCHEMA, Secret.COLLECTION_DEFAULT, + "Planify Claude API Key", api_key, null, + SECRET_KEY_ATTR, SECRET_KEY_VALUE); + update_status (); + } + + public void clear_api_key () throws Error { + Secret.password_clear_sync (SCHEMA, null, SECRET_KEY_ATTR, SECRET_KEY_VALUE); + update_status (); + } + + public string get_model () { + return Services.Settings.get_default ().settings.get_string ("claude-model"); + } + + private void update_status () { + status = is_configured () ? Status.CONFIGURED : Status.NOT_CONFIGURED; + status_changed (); + } + + public async string? send_request (string prompt) { + string? api_key = resolve_api_key (); + if (api_key == null) { + status = Status.NOT_CONFIGURED; + status_changed (); + return null; + } + + var builder = new Json.Builder (); + builder.begin_object (); + builder.set_member_name ("model"); + builder.add_string_value (get_model ()); + builder.set_member_name ("max_tokens"); + builder.add_int_value (2048); + builder.set_member_name ("messages"); + builder.begin_array (); + builder.begin_object (); + builder.set_member_name ("role"); + builder.add_string_value ("user"); + builder.set_member_name ("content"); + builder.add_string_value (prompt); + builder.end_object (); + builder.end_array (); + builder.end_object (); + + var generator = new Json.Generator (); + generator.set_root (builder.get_root ()); + string body = generator.to_data (null); + + var message = new Soup.Message ("POST", API_URL); + message.request_headers.append ("x-api-key", api_key); + message.request_headers.append ("anthropic-version", ANTHROPIC_VERSION); + message.request_headers.append ("content-type", "application/json"); + message.set_request_body_from_bytes ("application/json", new GLib.Bytes (body.data)); + + try { + GLib.Bytes stream = yield session.send_and_read_async ( + message, GLib.Priority.DEFAULT, null); + string response_body = (string) stream.get_data (); + + var parser = new Json.Parser (); + parser.load_from_data (response_body); + var root = parser.get_root ().get_object (); + + if (root.has_member ("error")) { + last_error = root.get_object_member ("error").get_string_member ("message"); + status = Status.ERROR; + status_changed (); + return null; + } + + status = Status.CONFIGURED; + status_changed (); + + return root + .get_array_member ("content") + .get_object_element (0) + .get_string_member ("text"); + } catch (Error e) { + last_error = e.message; + status = Status.ERROR; + status_changed (); + return null; + } + } + + public async bool ping () { + string? result = yield send_request ("Reply with only the word: OK"); + return result != null; + } +} +``` + +- [ ] **Step 4: Run tests and verify they pass** + +```bash +cd /home/jlagman/Projects/planify/build +ninja test-ai && meson test ai-unit --print-errorlogs 2>&1 +``` + +Expected: `1/1 ai-unit/ai-unit OK` + +- [ ] **Step 5: Commit** + +```bash +git add core/Services/AI/Claude.vala test/test-ai-unit.vala +git commit -m "feat: implement Claude.vala HTTP client with API key resolution" +``` + +--- + +## Task 4: `core/Services/AI/Prompts.vala` — prompt templates + +**Files:** +- Create: `core/Services/AI/Prompts.vala` +- Modify: `test/test-ai-unit.vala` + +- [ ] **Step 1: Write failing tests for prompt output shapes** + +Add to `test/test-ai-unit.vala` (before `main`): + +```vala +void test_parse_nl_prompt_contains_json_instruction () { + string prompt = Services.AI.Prompts.parse_natural_language ("buy milk tomorrow"); + assert (prompt.contains ("JSON")); + assert (prompt.contains ("buy milk tomorrow")); + assert (prompt.contains ("due_date")); + assert (prompt.contains ("priority")); +} + +void test_generate_subtasks_prompt_contains_task () { + string prompt = Services.AI.Prompts.generate_subtasks ("Launch website", ""); + assert (prompt.contains ("Launch website")); + assert (prompt.contains ("JSON")); +} + +void test_schedule_items_prompt_contains_today_date () { + string today = new GLib.DateTime.now_local ().format ("%Y-%m-%d"); + string prompt = Services.AI.Prompts.schedule_items ("[{\"id\":\"1\",\"title\":\"test\"}]"); + assert (prompt.contains (today)); + assert (prompt.contains ("suggested_due_date")); +} + +void test_map_import_file_prompt_includes_mime_hint () { + string prompt = Services.AI.Prompts.map_import_file ("# My Tasks\n- [ ] Do thing", "md"); + assert (prompt.contains ("md")); + assert (prompt.contains ("# My Tasks")); + assert (prompt.contains ("projects")); +} +``` + +Add to `main()`: +```vala +Test.add_func ("/ai/prompts/parse-nl", test_parse_nl_prompt_contains_json_instruction); +Test.add_func ("/ai/prompts/subtasks", test_generate_subtasks_prompt_contains_task); +Test.add_func ("/ai/prompts/schedule", test_schedule_items_prompt_contains_today_date); +Test.add_func ("/ai/prompts/import", test_map_import_file_prompt_includes_mime_hint); +``` + +- [ ] **Step 2: Run to verify failures** + +```bash +cd /home/jlagman/Projects/planify/build +ninja test-ai 2>&1 | tail -10 +``` + +Expected: compile error — `Services.AI.Prompts` methods not defined. + +- [ ] **Step 3: Implement `core/Services/AI/Prompts.vala`** + +```vala +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * GPL v3 — see LICENSE + */ + +public class Services.AI.Prompts : GLib.Object { + + public static string parse_natural_language (string input) { + return ( + "You are a task parser. Extract task details from the user's natural-language input.\n" + + "Return ONLY a valid JSON object with these fields (omit fields you cannot determine):\n" + + "{\n" + + " \"title\": \"task title as a clean imperative phrase\",\n" + + " \"due_date\": \"YYYY-MM-DD or null\",\n" + + " \"due_time\": \"HH:MM (24h) or null\",\n" + + " \"priority\": 1,\n" + + " \"labels\": [\"label1\"]\n" + + "}\n" + + "Priority scale: 1=urgent, 2=high, 3=medium, 4=none.\n" + + "Today is " + new GLib.DateTime.now_local ().format ("%Y-%m-%d") + ".\n" + + "User input: " + input + ); + } + + public static string generate_subtasks (string task_title, string task_description) { + string desc_part = task_description != "" ? + "\nDescription: " + task_description : ""; + return ( + "You are a task breakdown assistant. Break the following task into concrete, actionable subtasks.\n" + + "Return ONLY a valid JSON array of strings, each being a short subtask title.\n" + + "Maximum 8 subtasks. No numbering. No empty strings.\n" + + "Task: " + task_title + desc_part + ); + } + + public static string schedule_items (string items_json) { + return ( + "You are a productivity assistant. Suggest due dates and priorities for these tasks.\n" + + "Today is " + new GLib.DateTime.now_local ().format ("%Y-%m-%d") + ".\n" + + "Return ONLY a valid JSON array:\n" + + "[{\"id\": \"item_id\", \"suggested_due_date\": \"YYYY-MM-DD or null\", " + + "\"suggested_priority\": 1, \"reason\": \"brief reason\"}]\n" + + "Priority scale: 1=urgent, 2=high, 3=medium, 4=none.\n" + + "Tasks (JSON): " + items_json + ); + } + + public static string map_import_file (string content, string mime_hint) { + return ( + "You are a task import assistant. Parse this " + mime_hint + " file into tasks.\n" + + "Return ONLY a valid JSON object:\n" + + "{\n" + + " \"projects\": [\n" + + " {\n" + + " \"name\": \"project name\",\n" + + " \"sections\": [\n" + + " {\n" + + " \"name\": \"section name or empty string for no section\",\n" + + " \"items\": [\n" + + " {\"title\": \"task\", \"due_date\": \"YYYY-MM-DD or null\", " + + "\"priority\": 4, \"notes\": \"description or empty string\"}\n" + + " ]\n" + + " }\n" + + " ]\n" + + " }\n" + + " ],\n" + + " \"ambiguities\": [\n" + + " {\"line\": \"original text\", \"reason\": \"why unclear\", " + + "\"suggested_fix\": \"suggestion\"}\n" + + " ]\n" + + "}\n" + + "File content:\n" + content + ); + } +} +``` + +- [ ] **Step 4: Run tests and verify they all pass** + +```bash +cd /home/jlagman/Projects/planify/build +ninja test-ai && meson test ai-unit --print-errorlogs +``` + +Expected: `7/7 ai-unit/ai-unit OK` (3 from Task 3 + 4 new). + +- [ ] **Step 5: Commit** + +```bash +git add core/Services/AI/Prompts.vala test/test-ai-unit.vala +git commit -m "feat: implement Prompts.vala with all AI prompt templates" +``` + +--- + +## Task 5: `core/Services/AI/TaskParser.vala` + +**Files:** +- Create: `core/Services/AI/TaskParser.vala` + +- [ ] **Step 1: Implement `core/Services/AI/TaskParser.vala`** + +```vala +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * GPL v3 — see LICENSE + */ + +public struct Services.AI.ParsedItem { + public string title; + public string? due_date; + public string? due_time; + public int priority; + public string[] labels; +} + +public class Services.AI.TaskParser : GLib.Object { + + public async ParsedItem? parse_natural_language (string input) { + string prompt = Services.AI.Prompts.parse_natural_language (input); + string? response = yield Services.AI.Claude.get_default ().send_request (prompt); + if (response == null) return null; + + try { + string clean = extract_json_object (response); + var parser = new Json.Parser (); + parser.load_from_data (clean); + var obj = parser.get_root ().get_object (); + + ParsedItem result = ParsedItem (); + result.title = obj.has_member ("title") ? obj.get_string_member ("title") : input; + + result.due_date = (obj.has_member ("due_date") && obj.get_member ("due_date").get_node_type () != Json.NodeType.NULL) + ? obj.get_string_member ("due_date") : null; + + result.due_time = (obj.has_member ("due_time") && obj.get_member ("due_time").get_node_type () != Json.NodeType.NULL) + ? obj.get_string_member ("due_time") : null; + + result.priority = obj.has_member ("priority") + ? (int) obj.get_int_member ("priority") : Constants.PRIORITY_4; + + if (obj.has_member ("labels") && obj.get_member ("labels").get_node_type () == Json.NodeType.ARRAY) { + var arr = obj.get_array_member ("labels"); + result.labels = new string[arr.get_length ()]; + for (uint i = 0; i < arr.get_length (); i++) { + result.labels[i] = arr.get_string_element (i); + } + } else { + result.labels = new string[0]; + } + + return result; + } catch (Error e) { + Services.LogService.get_default ().error ("TaskParser", "Failed to parse response: " + e.message); + return null; + } + } + + public async string[]? generate_subtasks (Objects.Item parent) { + string prompt = Services.AI.Prompts.generate_subtasks (parent.content, parent.description); + string? response = yield Services.AI.Claude.get_default ().send_request (prompt); + if (response == null) return null; + + try { + string clean = extract_json_array (response); + var parser = new Json.Parser (); + parser.load_from_data (clean); + var arr = parser.get_root ().get_array (); + + string[] subtasks = new string[arr.get_length ()]; + for (uint i = 0; i < arr.get_length (); i++) { + subtasks[i] = arr.get_string_element (i); + } + return subtasks; + } catch (Error e) { + Services.LogService.get_default ().error ("TaskParser", "Failed to parse subtasks: " + e.message); + return null; + } + } + + private string extract_json_object (string text) { + int start = text.index_of ("{"); + int end = text.last_index_of ("}"); + if (start >= 0 && end > start) return text.slice (start, end + 1); + return text; + } + + private string extract_json_array (string text) { + int start = text.index_of ("["); + int end = text.last_index_of ("]"); + if (start >= 0 && end > start) return text.slice (start, end + 1); + return text; + } +} +``` + +- [ ] **Step 2: Verify the project still compiles** + +```bash +cd /home/jlagman/Projects/planify/build +ninja 2>&1 | tail -20 +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add core/Services/AI/TaskParser.vala +git commit -m "feat: implement TaskParser — NL parsing and subtask generation" +``` + +--- + +## Task 6: `core/Services/AI/ImportMapper.vala` + +**Files:** +- Create: `core/Services/AI/ImportMapper.vala` + +- [ ] **Step 1: Implement `core/Services/AI/ImportMapper.vala`** + +```vala +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * GPL v3 — see LICENSE + */ + +public struct Services.AI.MappedItem { + public string title; + public string? due_date; + public int priority; + public string notes; +} + +public struct Services.AI.MappedSection { + public string name; + public Gee.ArrayList items; +} + +public struct Services.AI.MappedProject { + public string name; + public Gee.ArrayList sections; +} + +public struct Services.AI.AmbiguityFlag { + public string line; + public string reason; + public string suggested_fix; +} + +public class Services.AI.ImportResult : GLib.Object { + public Gee.ArrayList projects; + public Gee.ArrayList ambiguities; + + public ImportResult () { + projects = new Gee.ArrayList (); + ambiguities = new Gee.ArrayList (); + } +} + +public class Services.AI.ImportMapper : GLib.Object { + private const int CHUNK_SIZE_BYTES = 50000; + + public async ImportResult? map_file (string content, string mime_hint) { + if (content.length <= CHUNK_SIZE_BYTES) { + return yield map_chunk (content, mime_hint); + } + + // Large file: split into chunks, map each, merge + var merged = new Services.AI.ImportResult (); + int offset = 0; + while (offset < content.length) { + int end = int.min (offset + CHUNK_SIZE_BYTES, (int) content.length); + string chunk = content.slice (offset, end); + Services.AI.ImportResult? chunk_result = yield map_chunk (chunk, mime_hint); + if (chunk_result != null) { + foreach (var p in chunk_result.projects) merged.projects.add (p); + foreach (var a in chunk_result.ambiguities) merged.ambiguities.add (a); + } + offset = end; + } + return merged; + } + + private async ImportResult? map_chunk (string content, string mime_hint) { + string prompt = Services.AI.Prompts.map_import_file (content, mime_hint); + string? response = yield Services.AI.Claude.get_default ().send_request (prompt); + if (response == null) return null; + + try { + string clean = extract_json_object (response); + var parser = new Json.Parser (); + parser.load_from_data (clean); + var root = parser.get_root ().get_object (); + + var result = new Services.AI.ImportResult (); + + if (root.has_member ("projects")) { + var projects_arr = root.get_array_member ("projects"); + projects_arr.foreach_element ((arr, idx, node) => { + var proj_obj = node.get_object (); + Services.AI.MappedProject project = Services.AI.MappedProject (); + project.name = proj_obj.get_string_member ("name"); + project.sections = new Gee.ArrayList (); + + if (proj_obj.has_member ("sections")) { + proj_obj.get_array_member ("sections").foreach_element ((sarr, sidx, snode) => { + var sec_obj = snode.get_object (); + Services.AI.MappedSection section = Services.AI.MappedSection (); + section.name = sec_obj.has_member ("name") ? sec_obj.get_string_member ("name") : ""; + section.items = new Gee.ArrayList (); + + if (sec_obj.has_member ("items")) { + sec_obj.get_array_member ("items").foreach_element ((iarr, iidx, inode) => { + var item_obj = inode.get_object (); + Services.AI.MappedItem item = Services.AI.MappedItem (); + item.title = item_obj.get_string_member ("title"); + item.due_date = (item_obj.has_member ("due_date") && + item_obj.get_member ("due_date").get_node_type () != Json.NodeType.NULL) + ? item_obj.get_string_member ("due_date") : null; + item.priority = item_obj.has_member ("priority") + ? (int) item_obj.get_int_member ("priority") : Constants.PRIORITY_4; + item.notes = item_obj.has_member ("notes") ? item_obj.get_string_member ("notes") : ""; + section.items.add (item); + }); + } + project.sections.add (section); + }); + } + result.projects.add (project); + }); + } + + if (root.has_member ("ambiguities")) { + root.get_array_member ("ambiguities").foreach_element ((arr, idx, node) => { + var amb_obj = node.get_object (); + Services.AI.AmbiguityFlag flag = Services.AI.AmbiguityFlag (); + flag.line = amb_obj.get_string_member ("line"); + flag.reason = amb_obj.get_string_member ("reason"); + flag.suggested_fix = amb_obj.get_string_member ("suggested_fix"); + result.ambiguities.add (flag); + }); + } + + return result; + } catch (Error e) { + Services.LogService.get_default ().error ("ImportMapper", "Failed to parse import: " + e.message); + return null; + } + } + + private string extract_json_object (string text) { + int start = text.index_of ("{"); + int end = text.last_index_of ("}"); + if (start >= 0 && end > start) return text.slice (start, end + 1); + return text; + } +} +``` + +- [ ] **Step 2: Verify build** + +```bash +cd /home/jlagman/Projects/planify/build +ninja 2>&1 | tail -10 +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add core/Services/AI/ImportMapper.vala +git commit -m "feat: implement ImportMapper with chunked file handling" +``` + +--- + +## Task 7: `core/Services/AI/Scheduler.vala` + +**Files:** +- Create: `core/Services/AI/Scheduler.vala` + +- [ ] **Step 1: Implement `core/Services/AI/Scheduler.vala`** + +```vala +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * GPL v3 — see LICENSE + */ + +public struct Services.AI.ScheduleSuggestion { + public string item_id; + public string? suggested_due_date; + public int suggested_priority; + public string reason; +} + +public class Services.AI.Scheduler : GLib.Object { + + public async Gee.ArrayList? suggest ( + Gee.ArrayList items) + { + if (items.is_empty) return null; + + string items_json = build_items_json (items); + string prompt = Services.AI.Prompts.schedule_items (items_json); + string? response = yield Services.AI.Claude.get_default ().send_request (prompt); + if (response == null) return null; + + try { + string clean = extract_json_array (response); + var parser = new Json.Parser (); + parser.load_from_data (clean); + var arr = parser.get_root ().get_array (); + + var suggestions = new Gee.ArrayList (); + arr.foreach_element ((a, idx, node) => { + var obj = node.get_object (); + Services.AI.ScheduleSuggestion s = Services.AI.ScheduleSuggestion (); + s.item_id = obj.get_string_member ("id"); + s.suggested_due_date = (obj.has_member ("suggested_due_date") && + obj.get_member ("suggested_due_date").get_node_type () != Json.NodeType.NULL) + ? obj.get_string_member ("suggested_due_date") : null; + s.suggested_priority = obj.has_member ("suggested_priority") + ? (int) obj.get_int_member ("suggested_priority") : Constants.PRIORITY_4; + s.reason = obj.has_member ("reason") ? obj.get_string_member ("reason") : ""; + suggestions.add (s); + }); + + return suggestions; + } catch (Error e) { + Services.LogService.get_default ().error ("Scheduler", "Failed to parse suggestions: " + e.message); + return null; + } + } + + private string build_items_json (Gee.ArrayList items) { + var builder = new Json.Builder (); + builder.begin_array (); + foreach (var item in items) { + builder.begin_object (); + builder.set_member_name ("id"); + builder.add_string_value (item.id); + builder.set_member_name ("title"); + builder.add_string_value (item.content); + builder.set_member_name ("due_date"); + if (item.due.date != null && item.due.date != "") { + builder.add_string_value (item.due.date); + } else { + builder.add_null_value (); + } + builder.set_member_name ("priority"); + builder.add_int_value (item.priority); + builder.end_object (); + } + builder.end_array (); + var gen = new Json.Generator (); + gen.set_root (builder.get_root ()); + return gen.to_data (null); + } + + private string extract_json_array (string text) { + int start = text.index_of ("["); + int end = text.last_index_of ("]"); + if (start >= 0 && end > start) return text.slice (start, end + 1); + return text; + } +} +``` + +- [ ] **Step 2: Verify build** + +```bash +cd /home/jlagman/Projects/planify/build +ninja 2>&1 | tail -10 +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add core/Services/AI/Scheduler.vala +git commit -m "feat: implement Scheduler — AI-powered due date and priority suggestions" +``` + +--- + +## Task 8: `src/Widgets/ClaudeStatusBadge.vala` + +**Files:** +- Create: `src/Widgets/ClaudeStatusBadge.vala` + +- [ ] **Step 1: Implement `src/Widgets/ClaudeStatusBadge.vala`** + +```vala +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * GPL v3 — see LICENSE + */ + +public class Widgets.ClaudeStatusBadge : Adw.Bin { + private Gtk.Image dot; + + construct { + dot = new Gtk.Image () { + pixel_size = 10, + valign = Gtk.Align.CENTER, + halign = Gtk.Align.CENTER + }; + child = dot; + + update_from_status (Services.AI.Claude.get_default ().status); + + Services.AI.Claude.get_default ().status_changed.connect (() => { + update_from_status (Services.AI.Claude.get_default ().status); + }); + } + + private void update_from_status (Services.AI.Claude.Status status) { + dot.remove_css_class ("success"); + dot.remove_css_class ("warning"); + dot.remove_css_class ("error"); + + switch (status) { + case Services.AI.Claude.Status.CONFIGURED: + dot.icon_name = "emblem-ok-symbolic"; + dot.add_css_class ("success"); + dot.tooltip_text = _("Claude is ready"); + break; + case Services.AI.Claude.Status.NOT_CONFIGURED: + dot.icon_name = "dialog-warning-symbolic"; + dot.add_css_class ("warning"); + dot.tooltip_text = _("Claude not configured — add API key in Preferences"); + break; + case Services.AI.Claude.Status.ERROR: + dot.icon_name = "dialog-error-symbolic"; + dot.add_css_class ("error"); + dot.tooltip_text = Services.AI.Claude.get_default ().last_error; + break; + } + } +} +``` + +- [ ] **Step 2: Verify build** + +```bash +cd /home/jlagman/Projects/planify/build +ninja 2>&1 | tail -10 +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/Widgets/ClaudeStatusBadge.vala +git commit -m "feat: add ClaudeStatusBadge widget with three-state indicator" +``` + +--- + +## Task 9: Preferences dialog — Claude section + +**Files:** +- Modify: `src/Dialogs/Preferences.vala` + +- [ ] **Step 1: Locate the preferences groups to find where to add the Claude section** + +```bash +grep -n "Adw.PreferencesGroup\|PreferencesPage\|add_group\|append" \ + /home/jlagman/Projects/planify/src/Dialogs/Preferences.vala | head -30 +``` + +Note the last group addition line number. + +- [ ] **Step 2: Add Claude preferences section** + +Add a new method `build_claude_group` to the preferences class and call it from `construct`. Add after the last existing group: + +```vala +private Adw.PreferencesGroup build_claude_group () { + var group = new Adw.PreferencesGroup () { + title = _("Claude AI"), + description = _("Task content is sent to Anthropic's API to process your requests.") + }; + + // API key row + var api_key_row = new Adw.PasswordEntryRow () { + title = _("API Key") + }; + string? stored_key = Services.AI.Claude.get_default ().resolve_api_key (); + if (stored_key != null && GLib.Environment.get_variable ("ANTHROPIC_API_KEY") == null) { + api_key_row.text = stored_key; + } else if (stored_key != null) { + api_key_row.placeholder_text = _("Set via ANTHROPIC_API_KEY environment variable"); + api_key_row.sensitive = false; + } + + api_key_row.apply.connect (() => { + try { + if (api_key_row.text.length > 0) { + Services.AI.Claude.get_default ().store_api_key (api_key_row.text); + } else { + Services.AI.Claude.get_default ().clear_api_key (); + } + } catch (Error e) { + Services.LogService.get_default ().error ("Preferences", e.message); + } + }); + + // Model selector + string[] model_ids = { + "claude-haiku-4-5-20251001", + "claude-sonnet-4-6", + "claude-opus-4-8" + }; + string[] model_labels = { + _("Haiku 4.5 — fastest"), + _("Sonnet 4.6 — balanced (recommended)"), + _("Opus 4.8 — most capable") + }; + + var model_row = new Adw.ComboRow () { + title = _("Model") + }; + var model_list = new Gtk.StringList (model_labels); + model_row.model = model_list; + + string current_model = Services.Settings.get_default ().settings.get_string ("claude-model"); + for (int i = 0; i < model_ids.length; i++) { + if (model_ids[i] == current_model) { model_row.selected = i; break; } + } + + model_row.notify["selected"].connect (() => { + Services.Settings.get_default ().settings.set_string ( + "claude-model", model_ids[model_row.selected]); + }); + + // Status row + var badge = new Widgets.ClaudeStatusBadge (); + var test_button = new Gtk.Button.with_label (_("Test connection")) { + valign = Gtk.Align.CENTER, + css_classes = { "flat" } + }; + test_button.clicked.connect (() => { + test_button.sensitive = false; + Services.AI.Claude.get_default ().ping.begin ((obj, res) => { + Services.AI.Claude.get_default ().ping.end (res); + test_button.sensitive = true; + }); + }); + + var status_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 6); + status_box.append (badge); + status_box.append (test_button); + + var status_row = new Adw.ActionRow () { + title = _("Connection status"), + activatable = false + }; + status_row.add_suffix (status_box); + + group.add (api_key_row); + group.add (model_row); + group.add (status_row); + + return group; +} +``` + +Call it from `construct` (or `build_ui`), adding the result to the preferences page: +```vala +page.add (build_claude_group ()); +``` + +- [ ] **Step 3: Verify build** + +```bash +cd /home/jlagman/Projects/planify/build +ninja 2>&1 | tail -10 +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/Dialogs/Preferences.vala +git commit -m "feat: add Claude AI section to Preferences dialog" +``` + +--- + +## Task 10: Quick-add bar — AI parse button + +**Files:** +- Modify: `core/QuickAddCore.vala` + +- [ ] **Step 1: Add parse toggle button field declaration** + +In `Layouts.QuickAddCore`, add to the private fields block (near `submit_button`): + +```vala +private Gtk.ToggleButton ai_parse_button; +private bool claude_available = false; +``` + +- [ ] **Step 2: Build the AI parse button in the `construct` block** + +Find where `submit_button` is built and add adjacent to it: + +```vala +ai_parse_button = new Gtk.ToggleButton () { + icon_name = "starred-symbolic", + tooltip_text = _("Parse with Claude AI (Ctrl+Shift+Enter)"), + css_classes = { "flat" }, + visible = false +}; +``` + +Add the button to the action bar/box that contains `submit_button`. + +- [ ] **Step 3: Show/hide based on Claude availability** + +Add to `construct`: + +```vala +update_ai_button_visibility (); +Services.AI.Claude.get_default ().status_changed.connect (update_ai_button_visibility); +``` + +Add method: + +```vala +private void update_ai_button_visibility () { + claude_available = Services.AI.Claude.get_default ().is_configured (); + ai_parse_button.visible = claude_available; +} +``` + +- [ ] **Step 4: Wire up Ctrl+Shift+Enter shortcut and parse action** + +Find the `shortcut_controller` setup (search for `Gtk.ShortcutController`) and add: + +```vala +var ai_shortcut = new Gtk.Shortcut ( + Gtk.ShortcutTrigger.parse_string ("Return"), + new Gtk.CallbackAction ((widget, args) => { + if (claude_available) { ai_parse_button.active = true; submit_item (); } + return true; + }) +); +shortcut_controller.add_shortcut (ai_shortcut); +``` + +- [ ] **Step 5: Intercept submit to run TaskParser when toggle is active** + +Find the method that handles item submission (search for `add_item_db`). Before creating the item literally, add: + +```vala +if (ai_parse_button.active && content_entry.text.strip ().length > 0) { + is_loading = true; + var parser = new Services.AI.TaskParser (); + parser.parse_natural_language.begin (content_entry.text, (obj, res) => { + Services.AI.ParsedItem? parsed = parser.parse_natural_language.end (res); + is_loading = false; + if (parsed != null) { + content_entry.text = parsed.title; + if (parsed.due_date != null) { + // apply due date using existing schedule_button API + var due = new Objects.DueDate (); + due.date = parsed.due_date; + if (parsed.due_time != null) due.date = parsed.due_date + "T" + parsed.due_time + ":00"; + item.due = due; + schedule_button.update_request (); + } + item.priority = parsed.priority; + priority_button.update_request (); + } + // let user review, don't auto-submit + }); + return; +} +``` + +- [ ] **Step 6: Verify build** + +```bash +cd /home/jlagman/Projects/planify/build +ninja 2>&1 | tail -10 +``` + +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add core/QuickAddCore.vala +git commit -m "feat: add Claude AI parse button to quick-add bar" +``` + +--- + +## Task 11: ItemRow — sparkle button for subtask generation + +**Files:** +- Modify: `src/Layouts/ItemRow.vala` + +- [ ] **Step 1: Add sparkle button field** + +In `Layouts.ItemRow`, add to the private fields block: + +```vala +private Gtk.Button ai_subtask_button; +private Gtk.Revealer ai_subtask_revealer; +``` + +- [ ] **Step 2: Build sparkle button and revealer** + +Find where `action_box_right` is built. Add: + +```vala +ai_subtask_button = new Gtk.Button.from_icon_name ("starred-symbolic") { + css_classes = { "flat", "circular" }, + tooltip_text = _("Generate subtasks with Claude AI"), + pixel_size = 16 +}; + +ai_subtask_revealer = new Gtk.Revealer () { + transition_type = Gtk.RevealerTransitionType.CROSSFADE, + child = ai_subtask_button, + reveal_child = false +}; +``` + +Append `ai_subtask_revealer` to `action_box_right`. + +- [ ] **Step 3: Show on hover, hide on leave** + +Find the existing `Gtk.EventControllerMotion` setup (or add one). In the enter handler: + +```vala +ai_subtask_revealer.reveal_child = Services.AI.Claude.get_default ().is_configured (); +``` + +In the leave handler: + +```vala +ai_subtask_revealer.reveal_child = false; +``` + +- [ ] **Step 4: Wire up subtask generation on click** + +```vala +ai_subtask_button.clicked.connect (() => { + ai_subtask_button.sensitive = false; + var parser = new Services.AI.TaskParser (); + parser.generate_subtasks.begin (item, (obj, res) => { + string[]? subtasks = parser.generate_subtasks.end (res); + ai_subtask_button.sensitive = true; + if (subtasks == null || subtasks.length == 0) return; + show_subtask_popover (subtasks); + }); +}); +``` + +- [ ] **Step 5: Implement `show_subtask_popover`** + +```vala +private void show_subtask_popover (string[] subtasks) { + var popover = new Gtk.Popover (); + popover.width_request = 300; + + var vbox = new Gtk.Box (Gtk.Orientation.VERTICAL, 6) { + margin_top = 8, margin_bottom = 8, + margin_start = 8, margin_end = 8 + }; + + var checks = new Gee.ArrayList (); + foreach (string title in subtasks) { + var cb = new Gtk.CheckButton.with_label (title) { active = true }; + vbox.append (cb); + checks.add (cb); + } + + var confirm_button = new Gtk.Button.with_label (_("Add selected")) { + css_classes = { "suggested-action" }, + margin_top = 4 + }; + vbox.append (confirm_button); + + confirm_button.clicked.connect (() => { + for (int i = 0; i < subtasks.length; i++) { + if (checks[i].active) { + var sub = new Objects.Item (); + sub.content = subtasks[i]; + sub.project_id = item.project_id; + sub.section_id = item.section_id; + sub.parent_id = item.id; + Services.Store.instance ().insert_item (sub); + } + } + popover.popdown (); + }); + + popover.child = vbox; + popover.set_parent (ai_subtask_button); + popover.popup (); +} +``` + +- [ ] **Step 6: Verify build** + +```bash +cd /home/jlagman/Projects/planify/build +ninja 2>&1 | tail -10 +``` + +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add src/Layouts/ItemRow.vala +git commit -m "feat: add sparkle subtask generation button to task rows" +``` + +--- + +## Task 12: AI Assistant panel (`Adw.BottomSheet`) + +**Files:** +- Create: `src/Views/AI/AssistantPanel.vala` +- Modify: `src/Layouts/Sidebar.vala` + +- [ ] **Step 1: Create `src/Views/AI/` directory and implement `AssistantPanel.vala`** + +```bash +mkdir -p /home/jlagman/Projects/planify/src/Views/AI +``` + +```vala +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * GPL v3 — see LICENSE + */ + +public class Views.AI.AssistantPanel : Adw.BottomSheet { + + private Gtk.Button prioritize_button; + private Gtk.Button schedule_button; + private Gtk.ListBox preview_list; + private Gtk.Revealer preview_revealer; + private Gtk.Button apply_button; + private Gtk.Button cancel_button; + private Gtk.Spinner spinner; + private Gtk.Label status_label; + + private Gee.ArrayList? pending_suggestions = null; + private Gee.ArrayList? current_items = null; + + construct { + can_open = true; + show_drag_handle = true; + + var content_box = new Gtk.Box (Gtk.Orientation.VERTICAL, 12) { + margin_top = 16, margin_bottom = 16, + margin_start = 16, margin_end = 16 + }; + + // Header + var header = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 8); + var title_label = new Gtk.Label (_("Claude Assistant")) { + css_classes = { "title-4" }, + hexpand = true, + xalign = 0 + }; + var badge = new Widgets.ClaudeStatusBadge (); + header.append (title_label); + header.append (badge); + + // Not-configured notice (shown when Claude unavailable) + var notice = new Adw.StatusPage () { + icon_name = "dialog-warning-symbolic", + title = _("Claude not configured"), + description = _("Add your API key in Preferences → Claude AI") + }; + var notice_revealer = new Gtk.Revealer () { + transition_type = Gtk.RevealerTransitionType.SLIDE_DOWN, + child = notice, + reveal_child = !Services.AI.Claude.get_default ().is_configured () + }; + + Services.AI.Claude.get_default ().status_changed.connect (() => { + bool configured = Services.AI.Claude.get_default ().is_configured (); + notice_revealer.reveal_child = !configured; + prioritize_button.sensitive = configured; + schedule_button.sensitive = configured; + }); + + // Action buttons + spinner = new Gtk.Spinner (); + prioritize_button = new Gtk.Button.with_label (_("Prioritize tasks")) { + css_classes = { "pill" }, + sensitive = Services.AI.Claude.get_default ().is_configured () + }; + schedule_button = new Gtk.Button.with_label (_("Schedule undated tasks")) { + css_classes = { "pill" }, + sensitive = Services.AI.Claude.get_default ().is_configured () + }; + + var actions_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 8) { + homogeneous = true + }; + actions_box.append (prioritize_button); + actions_box.append (schedule_button); + + status_label = new Gtk.Label ("") { xalign = 0 }; + + // Preview list + preview_list = new Gtk.ListBox () { + css_classes = { "boxed-list" } + }; + preview_revealer = new Gtk.Revealer () { + transition_type = Gtk.RevealerTransitionType.SLIDE_DOWN, + child = preview_list + }; + + apply_button = new Gtk.Button.with_label (_("Apply")) { + css_classes = { "suggested-action" } + }; + cancel_button = new Gtk.Button.with_label (_("Cancel")) { + css_classes = { "flat" } + }; + + var apply_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 8) { + halign = Gtk.Align.END + }; + apply_box.append (cancel_button); + apply_box.append (apply_button); + var apply_revealer = new Gtk.Revealer () { + transition_type = Gtk.RevealerTransitionType.SLIDE_DOWN, + child = apply_box + }; + + content_box.append (header); + content_box.append (notice_revealer); + content_box.append (actions_box); + content_box.append (spinner); + content_box.append (status_label); + content_box.append (preview_revealer); + content_box.append (apply_revealer); + + sheet = new Gtk.ScrolledWindow () { + child = content_box, + hscrollbar_policy = Gtk.PolicyType.NEVER + }; + + prioritize_button.clicked.connect (() => run_suggestions (false)); + schedule_button.clicked.connect (() => run_suggestions (true)); + + cancel_button.clicked.connect (() => { + pending_suggestions = null; + preview_revealer.reveal_child = false; + apply_revealer.reveal_child = false; + status_label.label = ""; + }); + + apply_button.clicked.connect (() => { + apply_suggestions (); + preview_revealer.reveal_child = false; + apply_revealer.reveal_child = false; + status_label.label = _("Applied!"); + }); + } + + private void run_suggestions (bool schedule_mode) { + current_items = get_current_items (schedule_mode); + if (current_items == null || current_items.is_empty) { + status_label.label = _("No tasks to process."); + return; + } + + spinner.spinning = true; + prioritize_button.sensitive = false; + schedule_button.sensitive = false; + status_label.label = _("Asking Claude…"); + + var sched = new Services.AI.Scheduler (); + sched.suggest.begin (current_items, (obj, res) => { + pending_suggestions = sched.suggest.end (res); + spinner.spinning = false; + prioritize_button.sensitive = true; + schedule_button.sensitive = true; + + if (pending_suggestions == null) { + status_label.label = _("Claude request failed — try again."); + return; + } + + status_label.label = _("Review suggested changes:"); + populate_preview (pending_suggestions); + preview_revealer.reveal_child = true; + apply_revealer.reveal_child = true; + }); + } + + private void populate_preview (Gee.ArrayList suggestions) { + while (preview_list.get_first_child () != null) + preview_list.remove (preview_list.get_first_child ()); + + foreach (var s in suggestions) { + Objects.Item? item = Services.Store.instance ().get_item (s.item_id); + if (item == null) continue; + + var row = new Adw.ActionRow () { + title = item.content, + subtitle = s.reason + }; + if (s.suggested_due_date != null) { + row.add_suffix (new Gtk.Label (s.suggested_due_date) { + css_classes = { "caption" }, valign = Gtk.Align.CENTER + }); + } + preview_list.append (row); + } + } + + private void apply_suggestions () { + if (pending_suggestions == null) return; + foreach (var s in pending_suggestions) { + Objects.Item? item = Services.Store.instance ().get_item (s.item_id); + if (item == null) continue; + if (s.suggested_due_date != null) { + item.due.date = s.suggested_due_date; + } + item.priority = s.suggested_priority; + item.update (); + } + pending_suggestions = null; + } + + private Gee.ArrayList? get_current_items (bool undated_only) { + // Get items from the currently visible project/filter + var items = new Gee.ArrayList (); + var today = new GLib.DateTime.now_local (); + var source_items = undated_only + ? Services.Store.instance ().get_items_by_scheduled (false) + : Services.Store.instance ().get_items_by_date (today, false); + foreach (var item in source_items) { + items.add (item); + } + return items; + } +} +``` + +- [ ] **Step 2: Add wand button to sidebar** + +In `src/Layouts/Sidebar.vala`, find where sidebar action buttons are added. Add: + +```vala +var ai_panel = new Views.AI.AssistantPanel (); + +var ai_wand_button = new Gtk.Button.from_icon_name ("starred-symbolic") { + tooltip_text = _("Claude AI Assistant"), + css_classes = { "flat", "circular" } +}; +ai_wand_button.clicked.connect (() => { + ai_panel.open = true; +}); + +// Add ai_panel as overlay child on the main window +// Add ai_wand_button to sidebar action row +``` + +Note: `Adw.BottomSheet` must be placed as a child of the top-level `Adw.ToolbarView` or `Adw.NavigationView`. Check where other overlay widgets are parented in `MainWindow.vala` and follow the same pattern. + +- [ ] **Step 3: Verify build** + +```bash +cd /home/jlagman/Projects/planify/build +ninja 2>&1 | tail -10 +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/Views/AI/AssistantPanel.vala src/Layouts/Sidebar.vala +git commit -m "feat: add AI assistant bottom sheet with prioritize/schedule actions" +``` + +--- + +## Task 13: Import dialog with Claude step + +**Files:** +- Create: `src/Dialogs/ImportDialog.vala` +- Modify: `src/meson.build` (add ImportDialog.vala) + +- [ ] **Step 1: Add `ImportDialog.vala` to `src/meson.build`** + +Find the dialogs section in `src/meson.build` and add: +``` +'Dialogs/ImportDialog.vala', +``` + +- [ ] **Step 2: Implement `src/Dialogs/ImportDialog.vala`** + +```vala +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * GPL v3 — see LICENSE + */ + +public class Dialogs.ImportDialog : Adw.Dialog { + + private Gtk.Stack stack; + private Gtk.Button import_button; + private Gtk.Button back_button; + private Gtk.Button confirm_button; + private Gtk.Spinner spinner; + private Gtk.Label status_label; + private Gtk.TreeView preview_tree; + private Gtk.TreeStore tree_store; + private Services.AI.ImportResult? current_result = null; + + construct { + title = _("Import Tasks"); + content_width = 560; + content_height = 500; + + stack = new Gtk.Stack () { + transition_type = Gtk.StackTransitionType.SLIDE_LEFT_RIGHT + }; + + // Page 1: file picker + var file_page = build_file_page (); + // Page 2: preview + ambiguity review + var preview_page = build_preview_page (); + + stack.add_named (file_page, "file"); + stack.add_named (preview_page, "preview"); + + var toolbar = new Adw.ToolbarView (); + toolbar.add_top_bar (new Adw.HeaderBar ()); + toolbar.content = stack; + + child = toolbar; + } + + private Gtk.Widget build_file_page () { + var vbox = new Gtk.Box (Gtk.Orientation.VERTICAL, 16) { + margin_top = 24, margin_bottom = 24, + margin_start = 24, margin_end = 24, + valign = Gtk.Align.CENTER + }; + + var icon = new Gtk.Image.from_icon_name ("document-open-symbolic") { + pixel_size = 64, opacity = 0.6 + }; + var label = new Gtk.Label (_("Select a Markdown or JSON file to import")) { + wrap = true, justify = Gtk.Justification.CENTER + }; + + import_button = new Gtk.Button.with_label (_("Choose file…")) { + css_classes = { "suggested-action", "pill" }, + halign = Gtk.Align.CENTER + }; + import_button.clicked.connect (open_file_chooser); + + spinner = new Gtk.Spinner (); + status_label = new Gtk.Label ("") { wrap = true, justify = Gtk.Justification.CENTER }; + + vbox.append (icon); + vbox.append (label); + vbox.append (import_button); + vbox.append (spinner); + vbox.append (status_label); + + return vbox; + } + + private Gtk.Widget build_preview_page () { + var vbox = new Gtk.Box (Gtk.Orientation.VERTICAL, 8) { + margin_top = 16, margin_bottom = 16, + margin_start = 16, margin_end = 16 + }; + + var header_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 8); + back_button = new Gtk.Button.with_label (_("← Back")) { css_classes = { "flat" } }; + var preview_label = new Gtk.Label (_("Preview import")) { + css_classes = { "title-4" }, hexpand = true, xalign = 0 + }; + header_box.append (back_button); + header_box.append (preview_label); + + // Tree store: col 0 = display text, col 1 = is ambiguous (bool), col 2 = type string + tree_store = new Gtk.TreeStore (3, typeof (string), typeof (bool), typeof (string)); + preview_tree = new Gtk.TreeView.with_model (tree_store) { + headers_visible = false, vexpand = true + }; + + var text_renderer = new Gtk.CellRendererText () { ellipsize = Pango.EllipsizeMode.END }; + var col = new Gtk.TreeViewColumn (); + col.pack_start (text_renderer, true); + col.add_attribute (text_renderer, "text", 0); + col.add_attribute (text_renderer, "foreground-rgba", 1); // amber tint for ambiguous + preview_tree.append_column (col); + + var scroll = new Gtk.ScrolledWindow () { + child = preview_tree, + hscrollbar_policy = Gtk.PolicyType.NEVER, + vexpand = true + }; + + confirm_button = new Gtk.Button.with_label (_("Import all")) { + css_classes = { "suggested-action" }, + halign = Gtk.Align.END + }; + + vbox.append (header_box); + vbox.append (scroll); + vbox.append (confirm_button); + + back_button.clicked.connect (() => stack.visible_child_name = "file"); + confirm_button.clicked.connect (do_import); + + return vbox; + } + + private void open_file_chooser () { + var dialog = new Gtk.FileDialog () { + title = _("Open file"), + modal = true + }; + var filter_md = new Gtk.FileFilter (); + filter_md.name = "Markdown / JSON"; + filter_md.add_pattern ("*.md"); + filter_md.add_pattern ("*.json"); + var filters = new GLib.ListStore (typeof (Gtk.FileFilter)); + filters.append (filter_md); + dialog.filters = filters; + + dialog.open.begin (null, null, (obj, res) => { + try { + var file = dialog.open.end (res); + process_file (file); + } catch (Error e) { + // user cancelled — no action + } + }); + } + + private void process_file (GLib.File file) { + import_button.sensitive = false; + spinner.spinning = true; + status_label.label = _("Analysing with Claude…"); + + string? content = null; + try { + uint8[] data; + file.load_contents (null, out data, null); + content = (string) data; + } catch (Error e) { + spinner.spinning = false; + import_button.sensitive = true; + status_label.label = _("Could not read file: ") + e.message; + return; + } + + string mime_hint = file.get_basename ().has_suffix (".json") ? "json" : "md"; + var mapper = new Services.AI.ImportMapper (); + mapper.map_file.begin (content, mime_hint, (obj, res) => { + current_result = mapper.map_file.end (res); + spinner.spinning = false; + import_button.sensitive = true; + + if (current_result == null || current_result.projects.is_empty) { + status_label.label = _("Claude couldn't identify any tasks — try a different file."); + return; + } + + populate_preview (current_result); + stack.visible_child_name = "preview"; + }); + } + + private void populate_preview (Services.AI.ImportResult result) { + tree_store.clear (); + foreach (var project in result.projects) { + Gtk.TreeIter proj_iter; + tree_store.append (out proj_iter, null); + tree_store.set (proj_iter, 0, "📁 " + project.name, 1, false, 2, "project"); + + foreach (var section in project.sections) { + Gtk.TreeIter sec_iter; + tree_store.append (out sec_iter, proj_iter); + string sec_name = section.name != "" ? "📂 " + section.name : _("(no section)"); + tree_store.set (sec_iter, 0, sec_name, 1, false, 2, "section"); + + foreach (var item in section.items) { + Gtk.TreeIter item_iter; + tree_store.append (out item_iter, sec_iter); + string label = "☐ " + item.title; + if (item.due_date != null) label += " · " + item.due_date; + tree_store.set (item_iter, 0, label, 1, false, 2, "item"); + } + } + } + + // Mark ambiguous rows + foreach (var amb in result.ambiguities) { + // Find and mark — simplified: add a warning row at top + Gtk.TreeIter warn_iter; + tree_store.insert (out warn_iter, null, 0); + tree_store.set (warn_iter, 0, "⚠ " + amb.line + " — " + amb.reason, 1, true, 2, "warning"); + } + + preview_tree.expand_all (); + } + + private void do_import () { + if (current_result == null) return; + foreach (var mp in current_result.projects) { + var project = new Objects.Project (); + project.name = mp.name; + Services.Store.instance ().insert_project (project); + + foreach (var ms in mp.sections) { + Objects.Section? section = null; + if (ms.name != "") { + section = new Objects.Section (); + section.name = ms.name; + section.project_id = project.id; + Services.Store.instance ().insert_section (section); + } + + foreach (var mi in ms.items) { + var item = new Objects.Item (); + item.content = mi.title; + item.description = mi.notes; + item.project_id = project.id; + item.section_id = section != null ? section.id : ""; + item.priority = mi.priority; + if (mi.due_date != null) item.due.date = mi.due_date; + Services.Store.instance ().insert_item (item); + } + } + } + close (); + } +} +``` + +- [ ] **Step 3: Wire up import dialog from a menu item** + +Find `src/Layouts/HeaderBar.vala` or wherever the app menu is. Add an "Import…" menu item that calls `new Dialogs.ImportDialog ().present (main_window)`. + +- [ ] **Step 4: Verify build** + +```bash +cd /home/jlagman/Projects/planify/build +ninja 2>&1 | tail -10 +``` + +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/Dialogs/ImportDialog.vala src/meson.build +git commit -m "feat: add import dialog with Claude AI file mapping and ambiguity review" +``` + +--- + +## Task 14: Final integration test run and cleanup + +- [ ] **Step 1: Run full test suite** + +```bash +cd /home/jlagman/Projects/planify/build +meson test --print-errorlogs 2>&1 +``` + +Expected: all suites pass (`cli`, `ai-unit`; `caldav-integration` may skip without env vars). + +- [ ] **Step 2: Add `.superpowers/` to `.gitignore`** + +```bash +echo ".superpowers/" >> /home/jlagman/Projects/planify/.gitignore +``` + +- [ ] **Step 3: Final commit** + +```bash +git add .gitignore +git commit -m "chore: ignore .superpowers brainstorm session files" +``` diff --git a/docs/superpowers/specs/2026-05-29-claude-ai-integration-design.md b/docs/superpowers/specs/2026-05-29-claude-ai-integration-design.md new file mode 100644 index 000000000..856f7f87e --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-claude-ai-integration-design.md @@ -0,0 +1,129 @@ +# Claude AI Integration — Design Spec +**Date:** 2026-05-29 +**Scope:** Sub-project 1 of 2 (Claude AI Features). Sub-project 2 (Export: Excel/CSV/MD/OmniFocus) is a separate spec. + +--- + +## 1. Architecture + +New folder: `core/Services/AI/` + +| File | Responsibility | +|------|---------------| +| `Claude.vala` | HTTP client (libsoup-3.0). Resolves API key. Holds selected model. Sends requests, parses JSON responses into typed structs, maps errors. The only file that touches the network. | +| `Prompts.vala` | Static class. One method per feature returning a complete prompt string. Never calls anything. | +| `TaskParser.vala` | Calls `Claude` + `Prompts`. Two methods: `parse_natural_language()` (raw string → `Objects.Item`) and `generate_subtasks()` (parent Item → list of sub-Items). | +| `ImportMapper.vala` | Calls `Claude` + `Prompts`. Input: file content + mime hint. Output: list of Projects/Sections/Items + ambiguity flags. | +| `Scheduler.vala` | Calls `Claude` + `Prompts`. Input: list of Items. Output: list of suggested due dates / priority order with human-readable reasons. | + +**Key constraint:** Feature files (`TaskParser`, `ImportMapper`, `Scheduler`) return typed Vala structs only — they never expose `json-glib` types to callers. All JSON parsing lives in `Claude.vala`. + +### API Key Resolution (runtime order) +1. `ANTHROPIC_API_KEY` environment variable +2. libsecret stored value (service: `io.github.alainm23.planify.claude`) +3. `null` → AI features disable with amber status indicator + +--- + +## 2. UI Surfaces + +### 2a. Quick-add bar (`quick-add/QuickAddCore.vala`) +- Icon-only "Parse with Claude" toggle button next to the submit button +- When active, submit sends raw text to `TaskParser` instead of creating a literal task +- Result pre-populates quick-add fields (title, due date, priority, labels) for user review before saving +- Keyboard shortcut: `Ctrl+Shift+Enter` to parse without toggling +- **Hidden entirely** (not dimmed) when Claude is unavailable + +### 2b. Task row inline (`src/Widgets/ProjectItemRow.vala`) +- Sparkle icon (✦) appears on hover at right edge of task row +- Click → calls `TaskParser.generate_subtasks()` with the parent task +- Suggestions appear as a popover checklist (max 300px wide for small screens) +- User checks which subtasks to keep → confirm creates them as sub-items +- Dimmed and non-interactive when Claude is unavailable + +### 2c. AI Assistant panel (`src/Views/AI/AssistantPanel.vala`, new) +- Triggered by a wand icon button in the sidebar +- Rendered as `Adw.BottomSheet` — slides up from bottom, preserves full task list width when closed +- Two actions: **Prioritize** (reorders tasks by Claude's suggested priority) and **Schedule** (assigns due dates to undated tasks) +- Changes shown as a diff-style preview with Claude's `reason` strings before applying +- When Claude unavailable: sheet opens to an error state explaining configuration + +### 2d. Import dialog (`src/Dialogs/ImportDialog.vala`, new or extended) +- Rendered as `Adw.Dialog` (centered modal; full-screen on small displays via libadwaita responsive breakpoint) +- After file selection, `ImportMapper` runs and produces a tree preview (projects → sections → tasks) +- Ambiguous rows highlighted in amber with an inline edit button +- User can fix, dismiss, or accept all → final import commits to database +- If file >100KB: chunked, mapped in sections, merged into one preview +- If file unrecognizable: shows "Claude couldn't identify any tasks — try a different file" + +--- + +## 3. Data Flow & API Contract + +All Claude calls are single-turn, non-streaming `messages` API requests. + +**TaskParser** +``` +parse_natural_language(): + Input: raw string + Output: Objects.Item { title, due_date, due_time, priority, labels[] } + +generate_subtasks(): + Input: parent Objects.Item + Output: list of Objects.Item (sub-items, no due date set) +``` + +**ImportMapper** +``` +Input: file_content (string), mime_hint ("md" | "json") +Output: list of { Project, Section[], Item[] } + ambiguity_flags: list of { line, reason, suggested_fix } +``` + +**Scheduler** +``` +Input: list of Items { item_id, title, due_date?, priority } +Output: list of { item_id, suggested_due_date, suggested_priority, reason } +``` + +--- + +## 4. Settings & API Key Management + +New "Claude" section in `src/Dialogs/Preferences.vala`: + +- **API key field** — `Adw.PasswordEntryRow` with built-in show/hide eye icon toggle. Shows "●●●●●●●● (saved)" when a key exists. Stored via libsecret. +- **Model selector** — `Gtk.DropDown` with three options (stored in GSettings): + - Haiku 4.5 — fastest + - Sonnet 4.6 — balanced *(default)* + - Opus 4.8 — most capable +- **Status row** — `ClaudeStatusBadge` + "Test connection" button (sends minimal API ping, updates badge live) +- **Disclosure** — one-line notice: "Task content is sent to Anthropic's API to process your requests." + +`ClaudeStatusBadge` widget (`src/Widgets/ClaudeStatusBadge.vala`): + +| State | Appearance | +|-------|-----------| +| Configured + reachable | Green dot | +| Not configured | Amber dot + tooltip "Claude not configured — add API key in Preferences" | +| Configured + error | Red dot + tooltip showing last error message | + +--- + +## 5. Error Handling & Edge Cases + +| Scenario | Behaviour | +|----------|-----------| +| No API key / network down | Amber badge on all AI surfaces; sparkle buttons dimmed; quick-add parse toggle hidden; bottom sheet opens to config error state | +| API timeout / rate limit / malformed response | Toast: "Claude request failed — try again" with retry button; no app state modified | +| Import file >100KB | Split into ~50KB chunks, each mapped separately, results merged into one preview | +| Completely unrecognizable import file | Interactive review shows "Claude couldn't identify any tasks — try a different file" | +| Env var key set | Silent — no libsecret interaction, no "key saved" indicator in preferences | + +--- + +## Out of Scope (this spec) +- Export formats (Excel, CSV, Markdown, OmniFocus) — Sub-project 2 +- OpenAI / Gemini / local LLM provider support +- Streaming responses +- Conversation history / multi-turn Claude sessions diff --git a/src/Dialogs/ImportDialog.vala b/src/Dialogs/ImportDialog.vala new file mode 100644 index 000000000..249addbac --- /dev/null +++ b/src/Dialogs/ImportDialog.vala @@ -0,0 +1,279 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +public class Dialogs.ImportDialog : Adw.Dialog { + private Gtk.Stack stack; + private Gtk.Button import_button; + private Gtk.Spinner spinner; + private Gtk.Label status_label; + private Gtk.ListBox preview_list; + private Gtk.Button confirm_button; + private Gtk.Button back_button; + private Services.AI.ImportResult? current_result = null; + + public ImportDialog () { + Object ( + title: _("Import Tasks"), + content_width: 560, + content_height: 500 + ); + } + + ~ImportDialog () { + debug ("Destroying - Dialogs.ImportDialog\n"); + } + + construct { + stack = new Gtk.Stack () { + transition_type = Gtk.StackTransitionType.SLIDE_LEFT_RIGHT + }; + + stack.add_named (build_file_page (), "file"); + stack.add_named (build_preview_page (), "preview"); + + var toolbar = new Adw.ToolbarView (); + toolbar.add_top_bar (new Adw.HeaderBar ()); + toolbar.content = stack; + + child = toolbar; + } + + private Gtk.Widget build_file_page () { + var vbox = new Gtk.Box (Gtk.Orientation.VERTICAL, 16) { + margin_top = 24, margin_bottom = 24, + margin_start = 24, margin_end = 24, + valign = Gtk.Align.CENTER, + vexpand = true + }; + + var icon = new Gtk.Image.from_icon_name ("document-open-symbolic") { + pixel_size = 64, + opacity = 0.6 + }; + + var label = new Gtk.Label (_("Select a Markdown or JSON file to import")) { + wrap = true, + justify = Gtk.Justification.CENTER + }; + + import_button = new Gtk.Button.with_label (_("Choose file…")) { + css_classes = { "suggested-action", "pill" }, + halign = Gtk.Align.CENTER + }; + + spinner = new Gtk.Spinner (); + + status_label = new Gtk.Label ("") { + wrap = true, + justify = Gtk.Justification.CENTER + }; + + import_button.clicked.connect (open_file_chooser); + + vbox.append (icon); + vbox.append (label); + vbox.append (import_button); + vbox.append (spinner); + vbox.append (status_label); + + return vbox; + } + + private Gtk.Widget build_preview_page () { + var vbox = new Gtk.Box (Gtk.Orientation.VERTICAL, 8) { + margin_top = 16, margin_bottom = 16, + margin_start = 16, margin_end = 16, + vexpand = true + }; + + var header_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 8); + back_button = new Gtk.Button.with_label (_("← Back")) { + css_classes = { "flat" } + }; + var preview_label = new Gtk.Label (_("Preview import")) { + css_classes = { "title-4" }, + hexpand = true, + xalign = 0 + }; + header_box.append (back_button); + header_box.append (preview_label); + + preview_list = new Gtk.ListBox () { + css_classes = { "boxed-list" }, + selection_mode = Gtk.SelectionMode.NONE + }; + + var scroll = new Gtk.ScrolledWindow () { + child = preview_list, + hscrollbar_policy = Gtk.PolicyType.NEVER, + vexpand = true + }; + + confirm_button = new Gtk.Button.with_label (_("Import all")) { + css_classes = { "suggested-action" }, + halign = Gtk.Align.END + }; + + back_button.clicked.connect (() => { + stack.visible_child_name = "file"; + }); + + confirm_button.clicked.connect (do_import); + + vbox.append (header_box); + vbox.append (scroll); + vbox.append (confirm_button); + + return vbox; + } + + private void open_file_chooser () { + var dialog = new Gtk.FileDialog () { + title = _("Open file"), + modal = true + }; + + var filter = new Gtk.FileFilter (); + filter.name = "Markdown / JSON"; + filter.add_pattern ("*.md"); + filter.add_pattern ("*.json"); + + var filters = new ListStore (typeof (Gtk.FileFilter)); + filters.append (filter); + dialog.filters = filters; + + dialog.open.begin (null, null, (obj, res) => { + try { + var file = dialog.open.end (res); + process_file (file); + } catch (Error e) { + // user cancelled — no action + } + }); + } + + private void process_file (GLib.File file) { + import_button.sensitive = false; + spinner.spinning = true; + status_label.label = _("Analysing with Claude…"); + + string content; + try { + uint8[] data; + file.load_contents (null, out data, null); + content = (string) data; + } catch (Error e) { + spinner.spinning = false; + import_button.sensitive = true; + status_label.label = _("Could not read file: ") + e.message; + return; + } + + string mime_hint = file.get_basename ().down ().has_suffix (".json") ? "json" : "md"; + + var mapper = new Services.AI.ImportMapper (); + mapper.map_file.begin (content, mime_hint, (obj, res) => { + current_result = mapper.map_file.end (res); + spinner.spinning = false; + import_button.sensitive = true; + + if (current_result == null || current_result.projects.is_empty) { + status_label.label = _("Claude couldn't identify any tasks — try a different file."); + return; + } + + populate_preview (current_result); + stack.visible_child_name = "preview"; + }); + } + + private void populate_preview (Services.AI.ImportResult result) { + while (preview_list.get_first_child () != null) { + preview_list.remove (preview_list.get_first_child ()); + } + + foreach (var mp in result.projects) { + var proj_row = new Adw.ActionRow () { + title = "📁 " + mp.name + }; + preview_list.append (proj_row); + + foreach (var ms in mp.sections) { + if (ms.name != "") { + var sec_row = new Adw.ActionRow () { + title = " 📂 " + ms.name + }; + preview_list.append (sec_row); + } + + foreach (var mi in ms.items) { + string item_text = " ☐ " + mi.title; + if (mi.due_date != null) item_text += " · " + mi.due_date; + var item_row = new Adw.ActionRow () { + title = item_text + }; + preview_list.append (item_row); + } + } + } + + // Add ambiguity warnings at top + foreach (var amb in result.ambiguities) { + var warn_row = new Adw.ActionRow () { + title = "⚠ " + amb.line, + subtitle = amb.reason, + css_classes = { "warning" } + }; + preview_list.prepend (warn_row); + } + } + + private void do_import () { + if (current_result == null) return; + + foreach (var mp in current_result.projects) { + var project = new Objects.Project (); + project.name = mp.name; + Services.Store.instance ().insert_project (project); + + foreach (var ms in mp.sections) { + Objects.Section? section = null; + if (ms.name != "") { + section = new Objects.Section (); + section.name = ms.name; + section.project_id = project.id; + Services.Store.instance ().insert_section (section); + } + + foreach (var mi in ms.items) { + var item = new Objects.Item (); + item.content = mi.title; + item.description = mi.notes; + item.project_id = project.id; + item.section_id = section != null ? section.id : ""; + item.priority = mi.priority; + if (mi.due_date != null) item.due.date = mi.due_date; + Services.Store.instance ().insert_item (item); + } + } + } + + close (); + } +} diff --git a/src/Dialogs/Preferences/Pages/Claude.vala b/src/Dialogs/Preferences/Pages/Claude.vala new file mode 100644 index 000000000..a0694acd8 --- /dev/null +++ b/src/Dialogs/Preferences/Pages/Claude.vala @@ -0,0 +1,172 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +public class Dialogs.Preferences.Pages.Claude : Dialogs.Preferences.Pages.BasePage { + public Claude (Adw.PreferencesDialog preferences_dialog) { + Object ( + preferences_dialog: preferences_dialog, + title: _("Claude AI") + ); + } + + ~Claude () { + debug ("Destroying - Dialogs.Preferences.Pages.Claude\n"); + } + + construct { + // Model data — parallel arrays indexed 0/1/2 + string[] model_labels = { + _("Haiku 4.5 — fastest"), + _("Sonnet 4.6 — balanced"), + _("Opus 4.8 — most capable") + }; + string[] model_ids = { + "claude-haiku-4-5-20251001", + "claude-sonnet-4-6", + "claude-opus-4-8" + }; + + // Row 1 — API key + var api_key_row = new Adw.PasswordEntryRow () { + show_apply_button = true + }; + api_key_row.title = _("API Key"); + + string? env_key = GLib.Environment.get_variable ("ANTHROPIC_API_KEY"); + if (env_key != null && env_key.length > 0) { + api_key_row.text = ""; + api_key_row.placeholder_text = _("Set via ANTHROPIC_API_KEY environment variable"); + api_key_row.sensitive = false; + } else { + string? stored_key = Services.AI.Claude.get_default ().resolve_api_key (); + if (stored_key != null && stored_key.length > 0) { + api_key_row.text = stored_key; + } + } + + // Row 2 — Model selector + var model_string_list = new Gtk.StringList (null); + foreach (string label in model_labels) { + model_string_list.append (label); + } + + var model_row = new Adw.ComboRow (); + model_row.title = _("Model"); + model_row.model = model_string_list; + + string current_model = Services.Settings.get_default ().settings.get_string ("claude-model"); + uint selected_index = 1; // default to Sonnet + for (uint i = 0; i < model_ids.length; i++) { + if (model_ids[i] == current_model) { + selected_index = i; + break; + } + } + model_row.selected = selected_index; + + // Row 3 — Connection status + var status_badge = new Widgets.ClaudeStatusBadge (); + + var test_button = new Gtk.Button.with_label (_("Test connection")) { + valign = Gtk.Align.CENTER + }; + test_button.add_css_class ("flat"); + + var suffix_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 8) { + valign = Gtk.Align.CENTER + }; + suffix_box.append (status_badge); + suffix_box.append (test_button); + + var connection_row = new Adw.ActionRow (); + connection_row.title = _("Connection"); + connection_row.activatable = false; + connection_row.add_suffix (suffix_box); + + // Group + var api_group = new Adw.PreferencesGroup (); + api_group.description = _("Task content is sent to Anthropic's API to process your requests."); + api_group.add (api_key_row); + api_group.add (model_row); + api_group.add (connection_row); + + // Layout + var content_box = new Gtk.Box (Gtk.Orientation.VERTICAL, 12) { + margin_start = 12, + margin_end = 12, + margin_bottom = 12, + margin_top = 6 + }; + content_box.append (api_group); + + var scrolled_window = new Gtk.ScrolledWindow () { + hscrollbar_policy = Gtk.PolicyType.NEVER, + hexpand = true, + vexpand = true + }; + scrolled_window.child = content_box; + + var toolbar_view = new Adw.ToolbarView (); + toolbar_view.add_top_bar (new Adw.HeaderBar ()); + toolbar_view.content = scrolled_window; + + child = toolbar_view; + + // Signals + signal_map[api_key_row.apply.connect (() => { + string text = api_key_row.text.strip (); + try { + if (text.length > 0) { + Services.AI.Claude.get_default ().store_api_key (text); + } else { + Services.AI.Claude.get_default ().clear_api_key (); + } + } catch (Error e) { + Services.LogService.get_default ().info ("Preferences.Claude", "API key error: %s".printf (e.message)); + } + })] = api_key_row; + + signal_map[model_row.notify["selected"].connect (() => { + uint idx = model_row.selected; + if (idx < model_ids.length) { + Services.Settings.get_default ().settings.set_string ("claude-model", model_ids[idx]); + } + })] = model_row; + + signal_map[test_button.clicked.connect (() => { + test_button.sensitive = false; + Services.AI.Claude.get_default ().ping.begin ((obj, res) => { + Services.AI.Claude.get_default ().ping.end (res); + test_button.sensitive = true; + }); + })] = test_button; + + destroy.connect (() => { + clean_up (); + }); + } + + public override void clean_up () { + foreach (var entry in signal_map.entries) { + entry.value.disconnect (entry.key); + } + + signal_map.clear (); + } +} diff --git a/src/Dialogs/Preferences/PreferencesWindow.vala b/src/Dialogs/Preferences/PreferencesWindow.vala index 2c0e44e1d..92b76efbc 100644 --- a/src/Dialogs/Preferences/PreferencesWindow.vala +++ b/src/Dialogs/Preferences/PreferencesWindow.vala @@ -132,6 +132,17 @@ public class Dialogs.Preferences.PreferencesWindow : Adw.PreferencesDialog { backups_row.title = _("Backups"); backups_row.subtitle = _("Backup or migrate from Planner."); + var claude_row = new Adw.ActionRow (); + claude_row.activatable = true; + claude_row.add_prefix (generate_icon ("starred-symbolic")); + claude_row.add_suffix (generate_icon ("go-next-symbolic")); + claude_row.title = _("Claude AI"); + claude_row.subtitle = _("AI-powered task assistance"); + + signal_map[claude_row.activated.connect (() => { + push_subpage (build_page ("claude-page")); + })] = claude_row; + var tutorial_row = new Adw.ActionRow (); tutorial_row.activatable = true; tutorial_row.add_prefix (generate_icon ("rescue-symbolic")); @@ -147,6 +158,7 @@ public class Dialogs.Preferences.PreferencesWindow : Adw.PreferencesDialog { personalization_group.add (appearance_row); personalization_group.add (quick_add_row); personalization_group.add (backups_row); + personalization_group.add (claude_row); personalization_group.add (tutorial_row); page.add (personalization_group); @@ -375,6 +387,9 @@ public class Dialogs.Preferences.PreferencesWindow : Adw.PreferencesDialog { case "donate": page_map[page] = new Dialogs.Preferences.Pages.Donate (this); break; + case "claude-page": + page_map[page] = new Dialogs.Preferences.Pages.Claude (this); + break; default: warning ("The page %s does not exist\n", page); return null; diff --git a/src/Layouts/ItemRow.vala b/src/Layouts/ItemRow.vala index e6b72efc8..0301863ac 100644 --- a/src/Layouts/ItemRow.vala +++ b/src/Layouts/ItemRow.vala @@ -89,6 +89,10 @@ public class Layouts.ItemRow : Layouts.ItemBase { private Gtk.Revealer add_subtasks_button_revealer; private Gtk.Button add_subtasks_button; + private Gtk.Button ai_subtask_button; + private Gtk.Revealer ai_subtask_revealer; + private Gtk.EventControllerMotion hover_ctrl; + private Widgets.SubItems subitems; private Gtk.MenuButton menu_button; private Gtk.Button hide_subtask_button; @@ -511,6 +515,40 @@ public class Layouts.ItemRow : Layouts.ItemBase { action_box_right.append (menu_button); + ai_subtask_button = new Gtk.Button.from_icon_name ("starred-symbolic") { + css_classes = { "flat", "circular" }, + tooltip_text = _("Generate subtasks with Claude AI"), + valign = Gtk.Align.CENTER + }; + + ai_subtask_revealer = new Gtk.Revealer () { + transition_type = Gtk.RevealerTransitionType.CROSSFADE, + child = ai_subtask_button, + reveal_child = false + }; + + action_box_right.prepend (ai_subtask_revealer); + + hover_ctrl = new Gtk.EventControllerMotion (); + signals_map[hover_ctrl.enter.connect ((x, y) => { + ai_subtask_revealer.reveal_child = Services.AI.Claude.get_default ().is_configured (); + })] = hover_ctrl; + signals_map[hover_ctrl.leave.connect (() => { + ai_subtask_revealer.reveal_child = false; + })] = hover_ctrl; + add_controller (hover_ctrl); + + signals_map[ai_subtask_button.clicked.connect (() => { + ai_subtask_button.sensitive = false; + var parser = new Services.AI.TaskParser (); + parser.generate_subtasks.begin (item, (obj, res) => { + string[]? subtasks = parser.generate_subtasks.end (res); + ai_subtask_button.sensitive = true; + if (subtasks == null || subtasks.length == 0) return; + show_subtask_popover (subtasks); + }); + })] = ai_subtask_button; + action_box.append (dates_box); action_box.append (action_box_right); @@ -2162,6 +2200,47 @@ public class Layouts.ItemRow : Layouts.ItemBase { markdown_handlerses.clear (); } + private void show_subtask_popover (string[] subtasks) { + var popover = new Gtk.Popover (); + popover.width_request = 300; + + var vbox = new Gtk.Box (Gtk.Orientation.VERTICAL, 6) { + margin_top = 8, margin_bottom = 8, + margin_start = 8, margin_end = 8 + }; + + var checks = new Gee.ArrayList (); + foreach (string title in subtasks) { + var cb = new Gtk.CheckButton.with_label (title) { active = true }; + vbox.append (cb); + checks.add (cb); + } + + var confirm_button = new Gtk.Button.with_label (_("Add selected")) { + css_classes = { "suggested-action" }, + margin_top = 4 + }; + vbox.append (confirm_button); + + confirm_button.clicked.connect (() => { + for (int i = 0; i < subtasks.length; i++) { + if (checks[i].active) { + var sub = new Objects.Item (); + sub.content = subtasks[i]; + sub.project_id = item.project_id; + sub.section_id = item.section_id; + sub.parent_id = item.id; + Services.Store.instance ().insert_item (sub); + } + } + popover.popdown (); + }); + + popover.child = vbox; + popover.set_parent (ai_subtask_button); + popover.popup (); + } + public override void clean_up () { foreach (var entry in signals_map.entries) { if (entry.value != null && GLib.SignalHandler.is_connected (entry.value, entry.key)) { @@ -2183,7 +2262,12 @@ public class Layouts.ItemRow : Layouts.ItemBase { if (priority_button != null) priority_button.clean_up (); if (label_button != null) label_button.clean_up (); if (reminder_button != null) reminder_button.clean_up (); - + + if (hover_ctrl != null) { + remove_controller (hover_ctrl); + hover_ctrl = null; + } + markdown_editor = null; } } diff --git a/src/MainWindow.vala b/src/MainWindow.vala index 8a267cf39..a4f1b8529 100644 --- a/src/MainWindow.vala +++ b/src/MainWindow.vala @@ -34,6 +34,7 @@ public class MainWindow : Adw.ApplicationWindow { private Widgets.ContextMenu.MenuSeparator archive_separator; private Adw.ToastOverlay toast_overlay; private Adw.ViewStack view_stack; + private Views.AI.AssistantPanel assistant_panel; private Gtk.Widget error_db_page; private string previous_inbox_project_id = ""; @@ -100,6 +101,15 @@ public class MainWindow : Adw.ApplicationWindow { sidebar_header.pack_end (settings_button); sidebar_header.pack_end (fake_button); + var ai_wand_button = new Gtk.Button.from_icon_name ("starred-symbolic") { + tooltip_text = _("Claude AI Assistant"), + css_classes = { "flat" } + }; + ai_wand_button.clicked.connect (() => { + assistant_panel.open_sheet (); + }); + sidebar_header.pack_end (ai_wand_button); + sidebar = new Layouts.Sidebar (); var sidebar_view = new Adw.ToolbarView (); @@ -132,8 +142,11 @@ public class MainWindow : Adw.ApplicationWindow { child = views_split_view }; + assistant_panel = new Views.AI.AssistantPanel (); + assistant_panel.set_sheet_content (toast_overlay); + overlay_split_view = new Adw.OverlaySplitView () { - content = toast_overlay, + content = assistant_panel, sidebar = sidebar_view }; @@ -746,6 +759,8 @@ public class MainWindow : Adw.ApplicationWindow { var preferences_item = new Widgets.ContextMenu.MenuItem (_("Preferences")); preferences_item.secondary_text = "Ctrl+,"; + var import_item = new Widgets.ContextMenu.MenuItem (_("Import Tasks…")); + var productivity_item = new Widgets.ContextMenu.MenuItem (Markup.escape_text (_("Summary & Productivity"))); var keyboard_shortcuts_item = new Widgets.ContextMenu.MenuItem (_("Keyboard Shortcuts")); @@ -759,6 +774,7 @@ public class MainWindow : Adw.ApplicationWindow { var menu_box = new Gtk.Box (Gtk.Orientation.VERTICAL, 0); menu_box.margin_top = menu_box.margin_bottom = 3; menu_box.append (preferences_item); + menu_box.append (import_item); menu_box.append (new Widgets.ContextMenu.MenuSeparator ()); menu_box.append (productivity_item); menu_box.append (new Widgets.ContextMenu.MenuSeparator ()); @@ -774,6 +790,12 @@ public class MainWindow : Adw.ApplicationWindow { position = Gtk.PositionType.BOTTOM }; + import_item.clicked.connect (() => { + popover.popdown (); + var import_dialog = new Dialogs.ImportDialog (); + import_dialog.present (this); + }); + productivity_item.clicked.connect (() => { popover.popdown (); var dialog = new Dialogs.ProductivityReport.ProductivityReportDialog (); diff --git a/src/Views/AI/AssistantPanel.vala b/src/Views/AI/AssistantPanel.vala new file mode 100644 index 000000000..cce2c150f --- /dev/null +++ b/src/Views/AI/AssistantPanel.vala @@ -0,0 +1,255 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +public class Views.AI.AssistantPanel : Adw.Bin { + private Adw.BottomSheet bottom_sheet; + + private Gtk.Button prioritize_button; + private Gtk.Button schedule_button_widget; + private Gtk.ListBox preview_list; + private Gtk.Revealer preview_revealer; + private Gtk.Button apply_button; + private Gtk.Button cancel_button; + private Gtk.Spinner spinner; + private Gtk.Label status_label; + + private Gee.ArrayList? pending_suggestions = null; + private Gee.ArrayList? current_items = null; + private ulong status_handler_id = 0; + + ~AssistantPanel () { + if (status_handler_id != 0) { + Services.AI.Claude.get_default ().disconnect (status_handler_id); + } + debug ("Destroying Views.AI.AssistantPanel\n"); + } + + construct { + bottom_sheet = new Adw.BottomSheet () { + can_open = false, + show_drag_handle = true + }; + + // Sheet content + var sheet_box = new Gtk.Box (Gtk.Orientation.VERTICAL, 12) { + margin_top = 16, margin_bottom = 24, + margin_start = 16, margin_end = 16 + }; + + // Header + var title_label = new Gtk.Label (_("Claude Assistant")) { + css_classes = { "title-4" }, + hexpand = true, + xalign = 0 + }; + var badge = new Widgets.ClaudeStatusBadge (); + var header_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 8); + header_box.append (title_label); + header_box.append (badge); + + // Not-configured notice + var notice_label = new Gtk.Label (_("Add your API key in Preferences → Claude AI")) { + css_classes = { "caption", "dim-label" }, + wrap = true + }; + var notice_revealer = new Gtk.Revealer () { + transition_type = Gtk.RevealerTransitionType.SLIDE_DOWN, + child = notice_label, + reveal_child = !Services.AI.Claude.get_default ().is_configured () + }; + + status_handler_id = Services.AI.Claude.get_default ().status_changed.connect (() => { + bool configured = Services.AI.Claude.get_default ().is_configured (); + notice_revealer.reveal_child = !configured; + prioritize_button.sensitive = configured; + schedule_button_widget.sensitive = configured; + }); + + // Action buttons + spinner = new Gtk.Spinner (); + bool configured = Services.AI.Claude.get_default ().is_configured (); + + prioritize_button = new Gtk.Button.with_label (_("Prioritize tasks")) { + css_classes = { "pill" }, + sensitive = configured, + hexpand = true + }; + schedule_button_widget = new Gtk.Button.with_label (_("Schedule undated tasks")) { + css_classes = { "pill" }, + sensitive = configured, + hexpand = true + }; + + var actions_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 8) { + homogeneous = true + }; + actions_box.append (prioritize_button); + actions_box.append (schedule_button_widget); + + status_label = new Gtk.Label ("") { xalign = 0, wrap = true }; + + // Preview list + preview_list = new Gtk.ListBox () { + css_classes = { "boxed-list" } + }; + preview_revealer = new Gtk.Revealer () { + transition_type = Gtk.RevealerTransitionType.SLIDE_DOWN, + child = preview_list + }; + + apply_button = new Gtk.Button.with_label (_("Apply")) { + css_classes = { "suggested-action" } + }; + cancel_button = new Gtk.Button.with_label (_("Cancel")) { + css_classes = { "flat" } + }; + + var apply_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 8) { + halign = Gtk.Align.END + }; + apply_box.append (cancel_button); + apply_box.append (apply_button); + var apply_revealer = new Gtk.Revealer () { + transition_type = Gtk.RevealerTransitionType.SLIDE_DOWN, + child = apply_box + }; + + sheet_box.append (header_box); + sheet_box.append (notice_revealer); + sheet_box.append (actions_box); + sheet_box.append (spinner); + sheet_box.append (status_label); + sheet_box.append (preview_revealer); + sheet_box.append (apply_revealer); + + var scroll = new Gtk.ScrolledWindow () { + child = sheet_box, + hscrollbar_policy = Gtk.PolicyType.NEVER + }; + + bottom_sheet.sheet = scroll; + child = bottom_sheet; + + // Wire up buttons + prioritize_button.clicked.connect (() => run_suggestions (false)); + schedule_button_widget.clicked.connect (() => run_suggestions (true)); + + cancel_button.clicked.connect (() => { + pending_suggestions = null; + preview_revealer.reveal_child = false; + apply_revealer.reveal_child = false; + status_label.label = ""; + }); + + apply_button.clicked.connect (() => { + apply_suggestions (); + preview_revealer.reveal_child = false; + apply_revealer.reveal_child = false; + status_label.label = _("Applied!"); + }); + } + + public void open_sheet () { + bottom_sheet.can_open = true; + bottom_sheet.open = true; + } + + public void set_sheet_content (Gtk.Widget content_widget) { + bottom_sheet.content = content_widget; + } + + private void run_suggestions (bool schedule_mode) { + current_items = get_current_items (schedule_mode); + if (current_items == null || current_items.is_empty) { + status_label.label = _("No tasks to process."); + return; + } + + spinner.spinning = true; + prioritize_button.sensitive = false; + schedule_button_widget.sensitive = false; + status_label.label = _("Asking Claude…"); + + var sched = new Services.AI.Scheduler (); + sched.suggest.begin (current_items, (obj, res) => { + pending_suggestions = sched.suggest.end (res); + spinner.spinning = false; + bool configured = Services.AI.Claude.get_default ().is_configured (); + prioritize_button.sensitive = configured; + schedule_button_widget.sensitive = configured; + + if (pending_suggestions == null) { + status_label.label = _("Claude request failed — try again."); + return; + } + + status_label.label = _("Review suggested changes:"); + populate_preview (pending_suggestions); + preview_revealer.reveal_child = true; + apply_revealer.reveal_child = true; + }); + } + + private void populate_preview (Gee.ArrayList suggestions) { + while (preview_list.get_first_child () != null) + preview_list.remove (preview_list.get_first_child ()); + + foreach (var s in suggestions) { + Objects.Item? found_item = Services.Store.instance ().get_item (s.item_id); + if (found_item == null) continue; + + var row = new Adw.ActionRow () { + title = found_item.content, + subtitle = s.reason + }; + if (s.suggested_due_date != null) { + row.add_suffix (new Gtk.Label (s.suggested_due_date) { + css_classes = { "caption" }, valign = Gtk.Align.CENTER + }); + } + preview_list.append (row); + } + } + + private void apply_suggestions () { + if (pending_suggestions == null) return; + foreach (var s in pending_suggestions) { + Objects.Item? found_item = Services.Store.instance ().get_item (s.item_id); + if (found_item == null) continue; + if (s.suggested_due_date != null) { + found_item.due.date = s.suggested_due_date; + } + found_item.priority = s.suggested_priority; + found_item.update (); + } + pending_suggestions = null; + } + + private Gee.ArrayList get_current_items (bool undated_only) { + var items = new Gee.ArrayList (); + var today = new GLib.DateTime.now_local (); + var source_items = undated_only + ? Services.Store.instance ().get_items_by_scheduled (false) + : Services.Store.instance ().get_items_by_date (today, false); + foreach (var it in source_items) { + items.add (it); + } + return items; + } +} diff --git a/src/Widgets/ClaudeStatusBadge.vala b/src/Widgets/ClaudeStatusBadge.vala new file mode 100644 index 000000000..bb8d7f1fd --- /dev/null +++ b/src/Widgets/ClaudeStatusBadge.vala @@ -0,0 +1,71 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +public class Widgets.ClaudeStatusBadge : Adw.Bin { + private Gtk.Image dot; + private ulong status_handler_id = 0; + + ~ClaudeStatusBadge () { + if (status_handler_id != 0) { + Services.AI.Claude.get_default ().disconnect (status_handler_id); + } + debug ("Destroying Widgets.ClaudeStatusBadge\n"); + } + + construct { + dot = new Gtk.Image () { + pixel_size = 16, + valign = Gtk.Align.CENTER, + halign = Gtk.Align.CENTER + }; + child = dot; + + update_from_status (Services.AI.Claude.get_default ().status); + + status_handler_id = Services.AI.Claude.get_default ().status_changed.connect (() => { + update_from_status (Services.AI.Claude.get_default ().status); + }); + } + + private void update_from_status (Services.AI.Claude.Status status) { + dot.remove_css_class ("success"); + dot.remove_css_class ("warning"); + dot.remove_css_class ("error"); + + switch (status) { + case Services.AI.Claude.Status.CONFIGURED: + dot.icon_name = "emblem-ok-symbolic"; + dot.add_css_class ("success"); + dot.tooltip_text = _("Claude is ready"); + break; + case Services.AI.Claude.Status.NOT_CONFIGURED: + dot.icon_name = "dialog-warning-symbolic"; + dot.add_css_class ("warning"); + dot.tooltip_text = _("Claude not configured — add API key in Preferences"); + break; + case Services.AI.Claude.Status.ERROR: + dot.icon_name = "dialog-error-symbolic"; + dot.add_css_class ("error"); + dot.tooltip_text = Services.AI.Claude.get_default ().last_error; + break; + default: + break; + } + } +} diff --git a/src/meson.build b/src/meson.build index 13e5894c6..ff64567ca 100644 --- a/src/meson.build +++ b/src/meson.build @@ -80,6 +80,7 @@ sources = files( 'Widgets/ProjectItemRow.vala', 'Widgets/SectionItemRow.vala', 'Widgets/ComboWrapRow.vala', + 'Widgets/ClaudeStatusBadge.vala', 'Views/Project/Project.vala', 'Views/Project/List.vala', @@ -89,7 +90,8 @@ sources = files( 'Views/Label/Labels.vala', 'Views/Label/LabelSourceRow.vala', 'Views/Filter.vala', - + 'Views/AI/AssistantPanel.vala', + 'Views/Scheduled/Scheduled.vala', 'Views/Scheduled/ScheduledSection.vala', 'Views/Scheduled/ScheduledDay.vala', @@ -101,6 +103,7 @@ sources = files( 'Dialogs/Section.vala', 'Dialogs/Label.vala', 'Dialogs/QuickAdd.vala', + 'Dialogs/ImportDialog.vala', 'Dialogs/ManageSectionOrder.vala', 'Dialogs/ProjectPicker.vala', 'Dialogs/ManageProjects.vala', @@ -130,6 +133,7 @@ sources = files( 'Dialogs/Preferences/Pages/TaskSetting.vala', 'Dialogs/Preferences/Pages/QuickAdd.vala', 'Dialogs/Preferences/Pages/Donate.vala', + 'Dialogs/Preferences/Pages/Claude.vala', 'Dialogs/Preferences/Pages/Accounts/Accounts.vala', 'Dialogs/Preferences/Pages/Accounts/NextcloudSetup.vala', 'Dialogs/Preferences/Pages/Accounts/CalDAVSetup.vala', diff --git a/test/meson.build b/test/meson.build index 24354cfb9..edf5ba142 100644 --- a/test/meson.build +++ b/test/meson.build @@ -24,3 +24,16 @@ test_cli = executable( ) test('cli', test_cli, timeout: 30, suite: ['cli']) + +test_ai_sources = [ + 'test-ai-unit.vala', +] + +test_ai = executable( + 'test-ai', + test_ai_sources, + dependencies: [ core_dep, glib_dep ], + install: false +) + +test('ai-unit', test_ai, timeout: 30, suite: ['ai-unit']) diff --git a/test/test-ai-unit.vala b/test/test-ai-unit.vala new file mode 100644 index 000000000..e2700d054 --- /dev/null +++ b/test/test-ai-unit.vala @@ -0,0 +1,86 @@ +using GLib; + +void test_api_key_env_var_takes_precedence () { + Environment.set_variable ("ANTHROPIC_API_KEY", "test-key-from-env", true); + var claude = new Services.AI.Claude (); + assert_cmpstr (claude.resolve_api_key (), CompareOperator.EQ, "test-key-from-env"); + Environment.unset_variable ("ANTHROPIC_API_KEY"); +} + +void test_api_key_returns_null_when_env_not_set_and_no_keyring () { + // Unset env var. In a CI/test environment with no keyring configured, + // resolve_api_key() must return null (no stored key). + // We can only assert this if ANTHROPIC_API_KEY is unset AND there's no + // keyring entry. If keyring has a stored value, skip the null assertion. + Environment.unset_variable ("ANTHROPIC_API_KEY"); + var claude = new Services.AI.Claude (); + string? key = claude.resolve_api_key (); + // If key is non-null, it came from keyring — that's also valid. + // We just verify it doesn't crash and returns a usable value or null. + if (key != null) { + assert (key.length > 0); // if returned, must be non-empty + } + // Verify is_configured() is consistent with resolve_api_key() + assert_cmpint ((int) claude.is_configured (), CompareOperator.EQ, (int) (key != null)); +} + +void test_status_starts_not_configured_when_no_key () { + Environment.unset_variable ("ANTHROPIC_API_KEY"); + var claude = new Services.AI.Claude (); + // Status should match whether a key is available + string? key = claude.resolve_api_key (); + if (key == null) { + assert (claude.status == Services.AI.Claude.Status.NOT_CONFIGURED); + } else { + assert (claude.status == Services.AI.Claude.Status.CONFIGURED); + } +} + +void test_env_var_key_makes_is_configured_true () { + Environment.set_variable ("ANTHROPIC_API_KEY", "any-test-key", true); + var claude = new Services.AI.Claude (); + assert (claude.is_configured ()); + assert (claude.resolve_api_key () != null); + Environment.unset_variable ("ANTHROPIC_API_KEY"); +} + +void test_parse_nl_prompt_contains_json_instruction () { + string prompt = Services.AI.Prompts.parse_natural_language ("buy milk tomorrow"); + assert (prompt.contains ("JSON")); + assert (prompt.contains ("buy milk tomorrow")); + assert (prompt.contains ("due_date")); + assert (prompt.contains ("priority")); +} + +void test_generate_subtasks_prompt_contains_task () { + string prompt = Services.AI.Prompts.generate_subtasks ("Launch website", ""); + assert (prompt.contains ("Launch website")); + assert (prompt.contains ("JSON")); +} + +void test_schedule_items_prompt_contains_today_date () { + string today = new GLib.DateTime.now_local ().format ("%Y-%m-%d"); + string prompt = Services.AI.Prompts.schedule_items ("[{\"id\":\"1\",\"title\":\"test\"}]"); + assert (prompt.contains (today)); + assert (prompt.contains ("suggested_due_date")); +} + +void test_map_import_file_prompt_includes_mime_hint () { + string prompt = Services.AI.Prompts.map_import_file ("# My Tasks\n- [ ] Do thing", "md"); + assert (prompt.contains ("md")); + assert (prompt.contains ("# My Tasks")); + assert (prompt.contains ("projects")); +} + +int main (string[] args) { + Test.init (ref args); + Test.add_func ("/ai/api-key-env-var-precedence", test_api_key_env_var_takes_precedence); + Test.add_func ("/ai/api-key-null-consistency", test_api_key_returns_null_when_env_not_set_and_no_keyring); + Test.add_func ("/ai/status-reflects-key-availability", test_status_starts_not_configured_when_no_key); + Test.add_func ("/ai/env-key-makes-configured", test_env_var_key_makes_is_configured_true); + Test.add_func ("/ai/prompts/parse-nl", test_parse_nl_prompt_contains_json_instruction); + Test.add_func ("/ai/prompts/subtasks", test_generate_subtasks_prompt_contains_task); + Test.add_func ("/ai/prompts/schedule", test_schedule_items_prompt_contains_today_date); + Test.add_func ("/ai/prompts/import", test_map_import_file_prompt_includes_mime_hint); + return Test.run (); +}