Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
82c394d
docs: add Claude AI integration design spec
JadeJitsu May 29, 2026
55cbe67
docs: add Claude AI integration implementation plan
JadeJitsu May 29, 2026
bbf019b
build: add Claude AI service stubs and meson wiring
JadeJitsu May 29, 2026
ccccb72
feat: add claude-model GSettings key
JadeJitsu May 29, 2026
0beb53e
feat: implement Claude.vala HTTP client with API key resolution
JadeJitsu May 29, 2026
ecf2d91
fix: remove duplicate content-type header, clear last_error on succes…
JadeJitsu May 29, 2026
32450e4
feat: implement Prompts.vala with all AI prompt templates
JadeJitsu May 29, 2026
519ea84
feat: implement TaskParser — NL parsing and subtask generation
JadeJitsu May 29, 2026
2e25eaa
feat: implement ImportMapper with chunked file handling
JadeJitsu May 29, 2026
98f91fd
feat: implement Scheduler — AI-powered due date and priority suggestions
JadeJitsu May 29, 2026
2e94153
fix: correct priority scale in prompts, add UTF-8 boundary and null g…
JadeJitsu May 29, 2026
3c3013a
feat: add ClaudeStatusBadge widget with three-state indicator
JadeJitsu May 29, 2026
0bee889
feat: add Claude AI section to Preferences dialog
JadeJitsu May 29, 2026
fa49b51
feat: add Claude AI parse button to quick-add bar
JadeJitsu May 29, 2026
ba3f769
feat: add sparkle subtask generation button to task rows
JadeJitsu May 29, 2026
82aafd3
fix: guard ai_parse_shortcut against concurrent parse while loading
JadeJitsu May 29, 2026
77d58d1
feat: add AI assistant bottom sheet panel with wand button
JadeJitsu May 29, 2026
d797188
fix: enable can_open before opening assistant bottom sheet
JadeJitsu May 29, 2026
1e6a2c6
feat: add import dialog with Claude AI file mapping and ambiguity review
JadeJitsu May 29, 2026
5d2e63e
chore: ignore .superpowers brainstorm session files
JadeJitsu May 29, 2026
e97d349
fix: disconnect singleton signals on destroy, add QuickAdd AI error f…
JadeJitsu May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ planify*txt
.flatpak
.buildconfig
.DS_Store
.superpowers/
71 changes: 71 additions & 0 deletions core/QuickAddCore.vala
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 () {
Expand Down Expand Up @@ -751,13 +761,30 @@ public class Layouts.QuickAddCore : Adw.Bin {
return true;
}));

var ai_parse_shortcut = new Gtk.Shortcut (
Gtk.ShortcutTrigger.parse_string ("<Control><Shift>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) => {
Expand All @@ -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");

Expand Down Expand Up @@ -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 ());

Expand Down
167 changes: 167 additions & 0 deletions core/Services/AI/Claude.vala
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading