Compare commits

..

34 Commits

Author SHA1 Message Date
2c74c0a058 refactor(ui): scroll mechanism — derive paged, defer render, split edges
Four-level refactor of the chat-window scrolling state:

L1. Extract win_page_space() and unify the two-branch clamp at the end of
    win_page_down into a single last_page_start formula. Removes the
    duplicated `getmaxy(stdscr) - 4` magic across page_up / page_down /
    sub_page_up / sub_page_down and the off-by-one between the two clamp
    branches.

L2. Remove ProfLayout::paged sticky flag. Replace with win_is_paged(),
    derived from y_pos and the current pad height. Eliminates the entire
    bug class of "stuck paged=1" — there is no flag to drift, no manual
    reset to forget. The earlier non-CHAT bottom-reach reset, the
    WIN_SCROLL_REACHED_BOTTOM reset, the win_clear paged=1 line, and the
    cl_ev_send_ai_msg paged=0 workaround all collapse into "y_pos governs
    everything." cl_ev_send_ai_msg now calls win_move_to_end so the user's
    own message is visible after scroll-up.

L3. _win_printf no longer drops messages when paged. The line is appended
    to the buffer and to the curses pad unconditionally; it lands below
    the visible viewport so the user's reading position is unchanged. The
    unread badge still increments. Suppressed messages are no longer lost
    forever — page-down naturally reveals them.

L4. Replace the win_scroll_state_t enum (INNER / REACHED_TOP /
    REACHED_BOTTOM, conflating layout edge with DB exhaustion) with a
    ScrollEdges struct of two independent booleans:
      db_exhausted_above — a previous page-up DB fetch returned empty
      db_exhausted_below — same for page-down
    The flags are flipped to TRUE on DB_RESPONSE_EMPTY and cleared by a
    scroll in the opposite direction (so MAM late-delivery and /history
    switch can re-trigger fetches). Buffer-edge state is no longer
    encoded here — y_pos handles that.

Tests: stub_ui gets a no-op win_move_to_end. 545/545 unit tests pass.
2026-05-10 16:17:01 +03:00
c167f48714 fix(ui): reset paged flag at buffer bottom for non-chat windows
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 10m34s
CI Code / Check spelling (pull_request) Failing after 11m11s
CI Code / Check coding style (pull_request) Failing after 11m42s
CI Code / Linux (ubuntu) (pull_request) Failing after 12m25s
CI Code / Linux (debian) (pull_request) Failing after 13m2s
CI Code / Linux (arch) (pull_request) Failing after 13m33s
2026-05-10 15:09:32 +03:00
010b2062a4 fix(ai): reset paged flag to display user message
Some checks failed
CI Code / Linux (arch) (pull_request) Failing after 5m11s
CI Code / Code Coverage (pull_request) Failing after 11m1s
CI Code / Check spelling (pull_request) Failing after 11m4s
CI Code / Check coding style (pull_request) Failing after 11m11s
CI Code / Linux (ubuntu) (pull_request) Failing after 11m16s
CI Code / Linux (debian) (pull_request) Failing after 11m18s
When the user scrolls up to view history, the paged flag is set to 1.
This suppresses new messages in _win_printf(). Reset paged and
unread_msg flags before printing to ensure visibility.
2026-05-10 10:35:57 +00:00
ecb07fd00c fix(ai): improve API error parsing and fallback
Some checks failed
CI Code / Check spelling (pull_request) Successful in 20s
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Code Coverage (pull_request) Successful in 2m32s
CI Code / Linux (debian) (pull_request) Failing after 3m37s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m48s
CI Code / Linux (arch) (pull_request) Failing after 4m40s
Add early return for empty responses and fallback to console output
when the AI window is closed or invalid. Implement JSON error
parsing to extract readable messages from API failures, improving
debugging and user feedback.
2026-05-09 21:06:09 +00:00
a898cb212d feat(ai): implement robust JSON model parsing
Some checks failed
CI Code / Check coding style (pull_request) Successful in 46s
CI Code / Linux (arch) (pull_request) Failing after 9m46s
CI Code / Code Coverage (pull_request) Failing after 14m8s
CI Code / Check spelling (pull_request) Failing after 14m16s
CI Code / Linux (ubuntu) (pull_request) Failing after 14m24s
CI Code / Linux (debian) (pull_request) Failing after 14m33s
Rewrote internal JSON parsing to correctly handle whitespace, escaped
quotes, and strict field context extraction. This prevents incorrect
field names or provider names from being added to the model list.

Added `ai_parse_models_from_json` public API to facilitate testing.

Changed `/ai models` default behavior to always fetch fresh models.
Replaced `--refresh` flag with `--cached` to display local cache.

Added comprehensive unit tests covering OpenAI and Perplexity formats,
array format, empty/null JSON, escaped quotes, and whitespace handling.
2026-05-09 13:56:59 +00:00
07d267b5bc fix(ai): protect window validation with mutex
Some checks failed
CI Code / Check coding style (pull_request) Successful in 53s
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m49s
CI Code / Linux (arch) (pull_request) Failing after 4m31s
CI Code / Code Coverage (pull_request) Successful in 2m23s
CI Code / Linux (debian) (pull_request) Failing after 5m58s
The existence check accesses shared state without synchronization,
which can cause race conditions when called from background threads.
Explicitly locking the mutex ensures safe access during validation.
2026-05-09 13:20:48 +00:00
acae543057 feat(ai): add model caching, settings, and commands
Some checks failed
CI Code / Check spelling (pull_request) Successful in 23s
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Code Coverage (pull_request) Successful in 2m28s
CI Code / Linux (debian) (pull_request) Failing after 6m18s
CI Code / Linux (arch) (pull_request) Failing after 8m46s
CI Code / Linux (ubuntu) (pull_request) Failing after 11m4s
- Introduce model caching with persistence to preferences
- Add provider default model and custom settings management
- Implement `/ai switch`, `/ai models`, and improve `/ai start`
- Add model name autocomplete for chat commands
- Update command definitions and help text
- Add unit tests for new functionality
2026-05-09 13:15:58 +00:00
731b55fa19 Merge branch 'master' into feat/ai
Some checks failed
CI Code / Check spelling (pull_request) Successful in 18s
CI Code / Check coding style (pull_request) Successful in 33s
CI Code / Code Coverage (pull_request) Successful in 2m29s
CI Code / Linux (debian) (pull_request) Failing after 3m35s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m46s
CI Code / Linux (arch) (pull_request) Failing after 4m32s
2026-05-06 13:57:13 +00:00
e229ed1281 fix(ai): return CURL_WRITEFUNC_ERROR on oversized response
Some checks failed
CI Code / Check coding style (pull_request) Successful in 1m4s
CI Code / Check spelling (pull_request) Successful in 1m10s
CI Code / Code Coverage (pull_request) Successful in 2m51s
CI Code / Linux (arch) (pull_request) Failing after 3m39s
CI Code / Linux (debian) (pull_request) Failing after 5m13s
CI Code / Linux (ubuntu) (pull_request) Failing after 5m21s
Return CURL_WRITEFUNC_ERROR instead of realsize when the response
exceeds 10MB. This causes curl to abort the transfer immediately
instead of continuing to stream data.
2026-05-01 19:15:46 +00:00
f4221e27ac fix(ai): fix leak from ai_get_provider_key
Some checks failed
CI Code / Check spelling (pull_request) Successful in 1m7s
CI Code / Check coding style (pull_request) Successful in 1m54s
CI Code / Linux (debian) (pull_request) Failing after 3m0s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m8s
CI Code / Code Coverage (pull_request) Successful in 2m53s
2026-05-01 18:50:21 +00:00
cdcc45b7c6 ref(pref): add newline at the end of the file 2026-05-01 18:47:46 +00:00
f133d81a05 fix(ai): use atomic operations for refcounting
Some checks failed
CI Code / Check coding style (pull_request) Successful in 1m8s
CI Code / Check spelling (pull_request) Successful in 1m7s
CI Code / Linux (debian) (pull_request) Failing after 2m58s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m8s
CI Code / Code Coverage (pull_request) Successful in 2m54s
Replace plain ref_count++/-- with g_atomic_int_inc and
g_atomic_int_dec_and_test for both AIProvider and AISession refcounts.
Prevents data races when ref/unref is called from worker threads.
2026-05-01 18:24:57 +00:00
a16237d16b fix(ai): convert \n escape sequences to actual newlines in LLM responses
Some checks failed
CI Code / Check spelling (pull_request) Successful in 51s
CI Code / Check coding style (pull_request) Successful in 1m7s
CI Code / Linux (debian) (pull_request) Failing after 2m56s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m5s
CI Code / Linux (arch) (pull_request) Failing after 3m5s
CI Code / Code Coverage (pull_request) Successful in 2m59s
2026-05-01 18:21:47 +00:00
cfe6ea46e2 feat(ai): disable request storage via store:false parameter
Some checks failed
CI Code / Check coding style (pull_request) Successful in 1m22s
CI Code / Linux (arch) (pull_request) Failing after 3m12s
CI Code / Check spelling (pull_request) Successful in 1m50s
CI Code / Linux (debian) (pull_request) Failing after 2m52s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m4s
CI Code / Code Coverage (pull_request) Successful in 2m49s
Add store:false to AI API requests to prevent OpenAI, Perplexity, and
other providers from storing conversation data or using it for training.

Both the OpenAI Responses API and Perplexity API support the store
parameter to control whether requests are persisted. Setting it to false
ensures conversations remain private and are not retained by the provider.

Privacy:
- Requests are not stored by AI providers
- Conversations are not used for model training
- Aligns with CProof's privacy-first design
2026-05-01 17:05:44 +00:00
2fc7f3d672 fix(ai): validate aiwin pointer to prevent UAF when window closed
Some checks failed
CI Code / Check spelling (pull_request) Successful in 53s
CI Code / Check coding style (pull_request) Successful in 1m11s
CI Code / Linux (debian) (pull_request) Failing after 2m58s
CI Code / Linux (arch) (pull_request) Failing after 3m11s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m10s
CI Code / Code Coverage (pull_request) Successful in 2m46s
Prevent use-after-free in the AI request worker thread when the user
closes the AI window during the 60-second HTTP request timeout. Without
this fix, the worker may dereference a dangling pointer and crash or
exhibit undefined behavior.

The _ai_request_thread function in ai_client.c previously cast user_data
to ProfAiWin* and used it directly without verifying the window was still
alive. Added wins_ai_exists() to iterate through all AI windows and check
if the pointer is still valid before use.

Safety:
- wins_ai_exists() iterates all AI windows (handles multiple AI windows)
- Worker skips UI update if window was freed, just cleans up
- Logs warning when dangling pointer is detected
2026-05-01 17:03:47 +00:00
fe0a46da58 refactor(ai): add UI display helpers
Some checks failed
CI Code / Check spelling (pull_request) Successful in 54s
CI Code / Check coding style (pull_request) Successful in 2m4s
CI Code / Linux (debian) (pull_request) Failing after 2m56s
CI Code / Linux (arch) (pull_request) Failing after 3m10s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m9s
CI Code / Code Coverage (pull_request) Successful in 2m55s
Add static _aiwin_display_error() and
_aiwin_display_response() helpers to reduce mutex repetition and
log warnings when aiwin is NULL or invalid.
2026-05-01 16:36:07 +00:00
cead417e78 refactor(ai): remove redundant callback layer from AI client
Some checks failed
CI Code / Check spelling (pull_request) Successful in 53s
CI Code / Check coding style (pull_request) Successful in 1m8s
CI Code / Linux (debian) (pull_request) Failing after 2m55s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m4s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Code Coverage (pull_request) Successful in 2m42s
The ai_response_cb and ai_error_cb parameters were always passed the
same functions (aiwin_display_response and aiwin_display_error),
making the callback indirection redundant and complicating the call
chain.

Remove ai_callback_data_t, _ai_callback_invoke(), and
_ai_invoke_callback(). Simplify _ai_request_thread and ai_send_prompt
to directly call aiwin_display_error() and aiwin_display_response()
when user_data is a valid ProfAiWin*.
2026-05-01 13:34:00 +00:00
00f11eb704 fix(ai): guard NULL search_str and update tests for new cycling behavior
Some checks failed
CI Code / Check spelling (pull_request) Successful in 21s
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Linux (debian) (pull_request) Failing after 2m56s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m7s
CI Code / Code Coverage (pull_request) Successful in 2m55s
CI Code / Linux (arch) (pull_request) Failing after 3m45s
Update ai_providers_find() to handle NULL search_str safely by treating it
as an empty string, preventing a segfault in strdup() within autocomplete_complete().

Rename test_ai_providers_find_case_sensitive to test_ai_providers_find_case_insensitive
to reflect the new case-insensitive matching contract. Update all affected tests to
expect cycling behavior (NULL/empty returns first provider) and use auto_gchar for
automatic memory management.

Use `gchar` instead of `char` in ai_providers_find for consistency
2026-05-01 13:08:28 +00:00
4913a3d5a4 ref(ai): remove inaccurate logging 2026-05-01 12:56:57 +00:00
634fb7d7eb fix(ai): standardize lock handling in _ai_invoke_callback
Some checks failed
CI Code / Check spelling (pull_request) Successful in 20s
CI Code / Check coding style (pull_request) Successful in 34s
CI Code / Code Coverage (pull_request) Failing after 1m6s
CI Code / Linux (debian) (pull_request) Failing after 2m59s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m11s
CI Code / Linux (arch) (pull_request) Failing after 3m19s
Take the global lock mutex before invoking all callbacks (success
and error) to ensure thread-safety. Previously some paths took the
lock and others did not, creating asymmetric behavior that could
lead to data races when callbacks access UI state.
2026-05-01 11:35:09 +00:00
461c0c32dd refactor(ai): use stateful autocomplete for provider name matching
Some checks failed
CI Code / Check spelling (pull_request) Successful in 57s
CI Code / Check coding style (pull_request) Successful in 1m4s
CI Code / Code Coverage (pull_request) Failing after 1m7s
CI Code / Linux (debian) (pull_request) Failing after 2m58s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m9s
CI Code / Linux (arch) (pull_request) Failing after 3m47s
Replace the manual GHashTable iteration in ai_providers_find() with the
existing stateful autocomplete system. Provider names are now kept in
sync via autocomplete_add/remove during ai_add_provider/ai_remove_provider,
and ai_providers_find delegates to autocomplete_complete() for deterministic
tab-completion cycling through sorted results.

This eliminates manual GList allocation, iteration, and cleanup, while
providing consistent forward/backward cycling behavior shared by all
autocomplete callers.
2026-04-30 23:16:12 +00:00
002a6ed15b refactor(ai): flatten autocomplete into sequential prefix-matching chain
Some checks failed
CI Code / Check spelling (pull_request) Successful in 1m5s
CI Code / Check coding style (pull_request) Successful in 2m7s
CI Code / Linux (arch) (pull_request) Failing after 3m9s
CI Code / Code Coverage (pull_request) Successful in 2m54s
CI Code / Linux (debian) (pull_request) Successful in 4m28s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m41s
Replace the deeply nested conditional tree in _ai_autocomplete() with a
flat sequential chain of autocomplete_param_with_*() calls. Each call
tries a specific command prefix (e.g., "/ai set provider", "/ai start")
and returns immediately on match — a common, reliable pattern for
command-line tab completion.

The previous design nested provider-name autocomplete inside
g_strv_length() and g_strcmp0() checks, meaning /ai<tab> and /ai <tab>
would never reach the subcommand matcher. The new flat structure ensures
every prefix is tried in order, with the most specific prefixes first
and the generic /ai subcommand fallback last.

This pattern — sequential prefix matching with early return — is the
standard approach for autocomplete dispatch: each handler owns its
prefix, no shared state or argument parsing is needed, and adding a
new subcommand is a single append to the chain.
2026-04-30 23:10:14 +00:00
dc75f16221 fix(ai): move generic subcommand autocomplete outside nested conditionals
Some checks failed
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Check coding style (pull_request) Successful in 1m8s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Code Coverage (pull_request) Successful in 2m58s
CI Code / Linux (debian) (pull_request) Successful in 4m22s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m35s
The generic `/ai <subcommand>` tab-completion was nested inside conditional
blocks that prevented it from firing in common cases — for example,
`/ai<tab>` or `/ai <tab>` would never match subcommands because the
`autocomplete_param_with_ac()` call was guarded by argument count checks
and nested inside the explicit handler branches.

Move the fallback subcommand autocomplete to execute unconditionally after
all explicit handler branches, ensuring `/ai <tab>` always lists available
subcommands regardless of argument count or which subcommand was typed.

Also move the `/ai set` subcommand fallback outside the inner else-block
so it runs whenever args[0] is "set", not only when num_args >= 2.
2026-04-30 22:51:07 +00:00
da0bf43d73 fix(ai): join multi-word arguments in cmd_ai_correct
Some checks failed
CI Code / Check spelling (pull_request) Successful in 24s
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Code Coverage (pull_request) Successful in 2m57s
CI Code / Linux (debian) (pull_request) Successful in 4m24s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m37s
Use g_strjoinv to join all arguments from args[1] onwards into a
single prompt string. Previously only args[1] was used, truncating
multi-word prompts like '/ai correct hello world' to just 'hello'
2026-04-30 19:24:39 +00:00
9e1f9b666e fix(ai): fix provider_name leak and const-cast in cmd_ai_start
Some checks failed
CI Code / Check spelling (pull_request) Successful in 55s
CI Code / Linux (arch) (pull_request) Failing after 3m25s
CI Code / Check coding style (pull_request) Successful in 1m38s
CI Code / Linux (debian) (pull_request) Successful in 4m42s
CI Code / Code Coverage (pull_request) Successful in 2m50s
CI Code / Linux (ubuntu) (pull_request) Successful in 8m12s
Use auto_gchar temporary for g_strndup result in cmd_ai_start to
prevent memory leak. Fix const-cast warning by using a proper
non-const temporary variable.
2026-04-30 19:14:49 +00:00
93ad7379e2 fix(ai): align ai_list_providers with documented contract
All checks were successful
CI Code / Check coding style (pull_request) Successful in 48s
CI Code / Check spelling (pull_request) Successful in 33s
CI Code / Linux (debian) (pull_request) Successful in 4m47s
CI Code / Linux (arch) (pull_request) Successful in 5m29s
CI Code / Code Coverage (pull_request) Successful in 2m45s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m29s
Remove ai_provider_ref() from ai_list_providers() so the returned
providers do not need to be unref'd by the caller. This matches the
header docstring which states "caller must not free the list or
providers". Also update all callers (cmd_ai_providers, tests) to
remove redundant ai_provider_unref() calls.
2026-04-30 19:09:46 +00:00
caa0b3ccba fix(ai): remove redundant g_strdup on api_key assignment
Some checks failed
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Check spelling (pull_request) Successful in 21s
CI Code / Linux (arch) (pull_request) Failing after 3m12s
CI Code / Code Coverage (pull_request) Successful in 2m47s
CI Code / Linux (debian) (pull_request) Successful in 4m21s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m53s
ai_get_provider_key() already returns a freshly g_strdup'd copy of
the API key. The outer g_strdup in ai_session_create() created an
unnecessary second copy, leaking the original pointer.
2026-04-30 19:06:49 +00:00
c9a5239117 fix(ai): remove API key from debug log
Some checks failed
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Linux (debian) (pull_request) Failing after 2m59s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m10s
CI Code / Code Coverage (pull_request) Successful in 2m58s
CI Code / Linux (arch) (pull_request) Failing after 3m59s
Remove the log_debug line in ai_client.c that leaked the first 10
characters of the API key into logs. This is also contained a memory leak (g_strndup
return value was never freed).
2026-04-30 19:05:33 +00:00
25e0459979 fix(ai): persist API keys to disk after set/remove operations
API keys set via /ai set token were only stored in memory and lost
on client restart. prefs_ai_set_token() and prefs_ai_remove_token()
mutated the in-memory prefs but never called _save_prefs(), unlike
other preference setters in the same file.

Both functions now call _save_prefs() after mutating the keyfile,
ensuring API keys persist across client restarts as advertised.
2026-04-30 19:01:10 +00:00
bccd3ecded fix(ai): remove duplicate assistant response in the message history
Some checks failed
CI Code / Check coding style (pull_request) Successful in 44s
CI Code / Linux (debian) (pull_request) Failing after 2m59s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m7s
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Code Coverage (pull_request) Successful in 2m46s
2026-04-30 18:05:51 +00:00
cff05ca802 ref(ai): move unfitting functions out of window.c; create aiwin.c
Some checks failed
CI Code / Check coding style (pull_request) Failing after 34s
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Linux (arch) (pull_request) Failing after 2m57s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m16s
CI Code / Linux (debian) (pull_request) Failing after 6m16s
CI Code / Code Coverage (pull_request) Successful in 7m12s
2026-04-30 18:01:24 +00:00
9c8ad57b59 ref(ai): add stub, fix includes, move aiwin_send_message location
Some checks failed
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Check coding style (pull_request) Successful in 34s
CI Code / Linux (debian) (pull_request) Failing after 2m57s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m9s
CI Code / Linux (arch) (pull_request) Failing after 3m13s
CI Code / Code Coverage (pull_request) Successful in 2m54s
2026-04-30 17:41:36 +00:00
1f1770bd58 fix(ai): set correct links in tests, fix headers 2026-04-30 17:02:54 +00:00
e43e8378b0 feat(ai): add AI client with multi-provider support and UI
Some checks failed
CI Code / Check coding style (pull_request) Successful in 47s
CI Code / Check spelling (pull_request) Successful in 59s
CI Code / Code Coverage (pull_request) Failing after 1m8s
CI Code / Linux (debian) (pull_request) Failing after 2m28s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m41s
CI Code / Linux (arch) (pull_request) Failing after 3m15s
Add an AI client module that integrates with OpenAI-compatible API
providers (OpenAI, Perplexity, and custom providers) to provide
AI-assisted responses within the profanity client.

The implementation includes:

- src/ai/ai_client.c/h: Core AI client with provider management,
  session handling, and async HTTP request handling via libcurl.
  Supports per-provider API keys stored in preferences, reference-
  counted sessions, and conversation history tracking.

- src/ui/window.c/window_list.c: New AI window type (ProfAiWin) for
  displaying AI conversations, with response streaming and error
  display capabilities.

- Command integration: New `/ai` command (cmd_defs.c, cmd_funcs.c)
  for creating sessions, sending prompts, and managing providers.
  Provider autocomplete support in cmd_ac.c.

- Preferences integration: API keys for providers are persisted in
  the preferences system (config/preferences.c).

- Unit tests: 472 lines of comprehensive tests covering provider
  management, session lifecycle, JSON escaping, and autocomplete
  (tests/unittests/test_ai_client.c).

Architecture decisions:
- Asynchronous design: HTTP requests run on a separate thread to
  avoid blocking the main UI loop. Callbacks are invoked on the main
  thread via direct function call (profanity uses ncurses, not GLib
  main loop).
- Reference counting: Both AIProvider and AISession use ref counting
  for safe shared ownership.
- Response size limit: 10MB cap on HTTP responses to prevent OOM.
2026-04-29 19:23:33 +00:00
31 changed files with 835 additions and 2380 deletions

View File

@@ -203,7 +203,6 @@ functionaltest_sources = \
tests/functionaltests/test_autoping.c tests/functionaltests/test_autoping.h \
tests/functionaltests/test_disco.c tests/functionaltests/test_disco.h \
tests/functionaltests/test_export_import.c tests/functionaltests/test_export_import.h \
tests/functionaltests/test_ai.c tests/functionaltests/test_ai.h \
tests/functionaltests/functionaltests.c
main_source = src/main.c

View File

@@ -21,9 +21,12 @@
#include <curl/curl.h>
#include <glib.h>
#include <pthread.h>
#include <string.h>
/* Default providers */
#define DEFAULT_OPENAI_URL "https://api.openai.com/"
#define DEFAULT_PERPLEXITY_URL "https://api.perplexity.ai/"
/* Global state */
static GHashTable* providers = NULL;
static GHashTable* provider_keys = NULL;
@@ -32,11 +35,8 @@ static Autocomplete providers_ac = NULL;
/* ========================================================================
* Forward declarations
* ======================================================================== */
static AIProvider* _ai_add_provider_nopersist(const gchar* name, const gchar* api_url);
static void _ai_load_models_for_provider(AIProvider* provider);
static void _ai_save_models_for_provider(AIProvider* provider);
static const gchar* _find_json_field(const gchar* json, const gchar* field);
static gchar* _extract_json_string(const gchar* json, const gchar* field);
/* ========================================================================
* Curl helpers
@@ -90,7 +90,9 @@ _aiwin_validate(gpointer user_data)
return NULL;
}
pthread_mutex_lock(&lock);
gboolean win_exists = wins_ai_exists((ProfAiWin*)user_data);
pthread_mutex_unlock(&lock);
if (!win_exists) {
log_warning("[AI-THREAD] aiwin=%p no longer exists — window was closed", (void*)user_data);
return NULL;
@@ -108,13 +110,12 @@ _aiwin_validate(gpointer user_data)
static void
_aiwin_display_error(gpointer user_data, const gchar* error_msg)
{
pthread_mutex_lock(&lock);
ProfAiWin* aiwin = _aiwin_validate(user_data);
if (!aiwin) {
log_warning("[AI-THREAD] Cannot display error: aiwin is invalid or window was closed (msg: %s)", error_msg);
pthread_mutex_unlock(&lock);
return;
}
pthread_mutex_lock(&lock);
aiwin_display_error(aiwin, error_msg);
pthread_mutex_unlock(&lock);
}
@@ -132,14 +133,16 @@ _aiwin_display_response(gpointer user_data, const gchar* response)
return;
}
pthread_mutex_lock(&lock);
/* Try to display in AI window if available */
ProfAiWin* aiwin = _aiwin_validate(user_data);
if (!aiwin) {
pthread_mutex_lock(&lock);
cons_show("%s", response);
pthread_mutex_unlock(&lock);
return;
}
pthread_mutex_lock(&lock);
aiwin_display_response(aiwin, response);
pthread_mutex_unlock(&lock);
}
@@ -186,95 +189,17 @@ ai_json_escape(const gchar* str)
return g_string_free(result, FALSE);
}
/* Decode JSON string escapes in [start, end) into a freshly allocated buffer.
* Handles \" \\ \/ \n \t \r \b \f. \uXXXX sequences are passed through verbatim
* since the rest of the AI pipeline already deals in UTF-8 byte streams. */
static gchar*
_json_unescape_substring(const gchar* start, const gchar* end)
{
gchar* out = g_new0(gchar, (end - start) + 1);
gchar* w = out;
for (const gchar* in = start; in < end;) {
if (*in == '\\' && in + 1 < end) {
switch (in[1]) {
case '"':
*w++ = '"';
in += 2;
break;
case '\\':
*w++ = '\\';
in += 2;
break;
case '/':
*w++ = '/';
in += 2;
break;
case 'n':
*w++ = '\n';
in += 2;
break;
case 't':
*w++ = '\t';
in += 2;
break;
case 'r':
*w++ = '\r';
in += 2;
break;
case 'b':
*w++ = '\b';
in += 2;
break;
case 'f':
*w++ = '\f';
in += 2;
break;
default:
*w++ = *in++;
break;
}
} else {
*w++ = *in++;
}
}
*w = '\0';
return out;
}
/* Locate "field":"...value..." in json and return the unescaped value.
* Skips JSON whitespace around the colon and treats any \X (X != \0) inside
* the string body as an opaque escape so an embedded \" cannot be misread as
* the closing quote. */
static gchar*
_extract_json_string(const gchar* json, const gchar* field)
{
const gchar* val = _find_json_field(json, field);
if (!val || *val != '"')
return NULL;
const gchar* start = val + 1;
for (const gchar* p = start; *p; p++) {
if (*p == '\\' && *(p + 1) != '\0') {
p++; /* skip the escaped character; loop ++ advances past it */
continue;
}
if (*p == '"') {
return _json_unescape_substring(start, p);
}
}
return NULL;
}
/* ========================================================================
* Provider Management
* ======================================================================== */
static AIProvider*
ai_provider_new(const gchar* name, const gchar* api_url)
ai_provider_new(const gchar* name, const gchar* api_url, const gchar* org_id)
{
AIProvider* provider = g_new0(AIProvider, 1);
provider->name = g_strdup(name);
provider->api_url = g_strdup(api_url ? api_url : "");
provider->org_id = g_strdup(org_id);
provider->project_id = NULL;
provider->default_model = NULL;
provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
@@ -284,7 +209,7 @@ ai_provider_new(const gchar* name, const gchar* api_url)
return provider;
}
AIProvider*
static AIProvider*
ai_provider_ref(AIProvider* provider)
{
if (provider) {
@@ -305,6 +230,7 @@ ai_provider_unref(AIProvider* provider)
g_free(provider->name);
g_free(provider->api_url);
g_free(provider->org_id);
g_free(provider->project_id);
g_free(provider->default_model);
g_hash_table_destroy(provider->settings);
@@ -361,51 +287,24 @@ ai_client_init(void)
/* Create autocomplete for provider names */
providers_ac = autocomplete_new();
/* Get all providers (defaults if nothing configured, otherwise config values) */
GList* all_providers = prefs_ai_get_providers();
for (GList* curr = all_providers; curr; curr = g_list_next(curr)) {
/* List contains pairs: name, url, name, url, ... */
gchar* name = (gchar*)curr->data;
curr = g_list_next(curr);
if (!curr)
break;
gchar* url = (gchar*)curr->data;
_ai_add_provider_nopersist(name, url);
}
prefs_free_ai_providers(all_providers);
/* Add default providers */
ai_add_provider("openai", DEFAULT_OPENAI_URL, NULL);
ai_add_provider("perplexity", DEFAULT_PERPLEXITY_URL, NULL);
/* Load saved API keys from config */
ai_load_keys();
/* Load cached models, persisted default-model and custom settings for
* all providers. */
/* Load cached models for all providers */
GList* provider_list = ai_list_providers();
for (GList* curr = provider_list; curr; curr = g_list_next(curr)) {
GList* curr = provider_list;
while (curr) {
AIProvider* provider = (AIProvider*)curr->data;
_ai_load_models_for_provider(provider);
auto_gchar gchar* dm = prefs_ai_get_default_model(provider->name);
if (dm && dm[0] != '\0') {
g_free(provider->default_model);
provider->default_model = g_strdup(dm);
}
GList* settings = prefs_ai_list_settings(provider->name);
for (GList* s = settings; s; s = g_list_next(s)) {
const gchar* name = (const gchar*)s->data;
auto_gchar gchar* val = prefs_ai_get_setting(provider->name, name);
if (val) {
if (!provider->settings) {
provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
}
g_hash_table_insert(provider->settings, g_strdup(name), g_strdup(val));
}
}
prefs_free_ai_settings(settings);
curr = g_list_next(curr);
}
g_list_free(provider_list);
log_info("AI client initialized with providers from preferences");
log_info("AI client initialized with default providers: openai, perplexity");
}
void
@@ -436,31 +335,8 @@ ai_get_provider(const gchar* name)
return g_hash_table_lookup(providers, name);
}
/* Internal helper to add provider without persisting to config.
* Used for default providers and loading from config on startup. */
static AIProvider*
_ai_add_provider_nopersist(const gchar* name, const gchar* api_url)
{
/* Check if provider already exists */
AIProvider* existing = g_hash_table_lookup(providers, name);
if (existing) {
g_free(existing->api_url);
existing->api_url = g_strdup(api_url);
return ai_provider_ref(existing);
}
/* Create new provider (ref_count=1 owned by hash table) */
AIProvider* provider = ai_provider_new(name, api_url);
g_hash_table_insert(providers, g_strdup(name), provider);
/* Sync autocomplete */
autocomplete_add(providers_ac, name);
return provider;
}
AIProvider*
ai_add_provider(const gchar* name, const gchar* api_url)
ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id)
{
if (!name || !api_url)
return NULL;
@@ -475,17 +351,18 @@ ai_add_provider(const gchar* name, const gchar* api_url)
/* Update existing provider */
g_free(existing->api_url);
existing->api_url = g_strdup(api_url);
/* Persist the updated URL to config */
prefs_ai_set_provider(name, api_url);
g_free(existing->org_id);
existing->org_id = g_strdup(org_id);
log_info("Updated provider: %s", name);
return ai_provider_ref(existing);
}
/* Persist the new provider to config */
prefs_ai_set_provider(name, api_url);
/* Create new provider (ref_count=1 owned by hash table) */
AIProvider* provider = ai_provider_new(name, api_url, org_id);
g_hash_table_insert(providers, g_strdup(name), provider);
/* Add to hash table (reuse internal helper) */
AIProvider* provider = _ai_add_provider_nopersist(name, api_url);
/* Sync autocomplete */
autocomplete_add(providers_ac, name);
log_info("Added provider: %s (URL: %s)", name, api_url);
return provider; /* Caller gets non-owning pointer; hash table owns ref */
@@ -497,10 +374,6 @@ ai_remove_provider(const gchar* name)
if (!name || !providers)
return FALSE;
/* Wipe the whole [ai/<name>] subsection from config — url, key,
* default_model, models, custom settings — in one call. */
prefs_ai_remove_section(name);
/* Sync autocomplete before removing */
autocomplete_remove(providers_ac, name);
@@ -529,26 +402,53 @@ ai_list_providers(void)
* Provider autocomplete state
* ======================================================================== */
/* Stateful provider name finder with case-sensitive matching.
* Maintains position in sorted list for deterministic tab-completion cycling.
* The autocomplete is kept in sync via ai_add_provider/ai_remove_provider. */
gchar*
ai_providers_find(const char* const search_str, gboolean previous, void* context)
{
/* Initialize autocomplete on first use */
if (!providers_ac) {
log_debug("[AI-AC] Uninitialized call to ai_providers_find");
providers_ac = autocomplete_new();
}
/* NULL search_str is treated as empty string for cycling */
const char* effective_search = (search_str != NULL) ? search_str : "";
/* Use stateful autocomplete */
return autocomplete_complete(providers_ac, effective_search, FALSE, previous);
}
/* Stateful model name finder for autocomplete.
* Uses the current session's provider models for completion. */
gchar*
ai_models_find(const char* const search_str, gboolean previous, void* context)
{
ProfAiWin* aiwin = (ProfAiWin*)context;
if (!aiwin || !aiwin->session) {
return NULL;
}
return autocomplete_complete(providers_ac, search_str, FALSE, previous);
}
/* Reset the provider autocomplete state.
* Called from cmd_ac_reset() to clear autocomplete state when the user presses Ctrl+C. */
void
ai_providers_reset_ac(void)
{
if (!providers_ac) {
return;
AIProvider* provider = aiwin->session->provider;
if (!provider || !provider->models) {
return NULL;
}
autocomplete_reset(providers_ac);
/* Create a temporary autocomplete for models */
Autocomplete models_ac = autocomplete_new();
GList* curr = provider->models;
while (curr) {
autocomplete_add(models_ac, curr->data);
curr = g_list_next(curr);
}
/* NULL search_str is treated as empty string for cycling */
const char* effective_search = (search_str != NULL) ? search_str : "";
gchar* result = autocomplete_complete(models_ac, effective_search, FALSE, previous);
autocomplete_free(models_ac);
return result;
}
gchar*
@@ -601,7 +501,6 @@ ai_set_provider_default_model(const gchar* provider_name, const gchar* model)
g_free(provider->default_model);
provider->default_model = g_strdup(model);
prefs_ai_set_default_model(provider_name, model);
log_info("Default model for provider '%s' set to: %s", provider_name, model);
}
@@ -636,11 +535,9 @@ ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const
if (value) {
g_hash_table_insert(provider->settings, g_strdup(setting), g_strdup(value));
prefs_ai_set_setting(provider_name, setting, value);
log_info("Setting '%s' for provider '%s' set to: %s", setting, provider_name, value);
} else {
g_hash_table_remove(provider->settings, setting);
prefs_ai_remove_setting(provider_name, setting);
log_info("Setting '%s' removed for provider '%s'", setting, provider_name);
}
}
@@ -674,7 +571,7 @@ typedef struct
} ai_request_t;
/**
* Build curl headers with auth.
* Build curl headers with auth and optional org header.
* Returns headers list (caller must free with curl_slist_free_all).
*/
static struct curl_slist*
@@ -685,6 +582,11 @@ _build_curl_headers(AIProvider* provider, const gchar* api_key)
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = curl_slist_append(headers, auth_header);
if (provider && provider->org_id && strlen(provider->org_id) > 0) {
auto_gchar gchar* org_header = g_strdup_printf("OpenAI-Organization: %s", provider->org_id);
headers = curl_slist_append(headers, org_header);
}
return headers;
}
@@ -712,11 +614,8 @@ _curl_exec_and_handle(CURL* curl, const gchar* url, struct curl_slist* headers,
log_error("Request failed for '%s': %s", provider->name, error_msg);
_aiwin_display_error(user_data, error_msg);
} else if (http_code >= 400) {
auto_gchar gchar* parsed = ai_parse_error_message(response->data);
auto_gchar gchar* error_msg = parsed
? g_strdup_printf("HTTP %ld: %s", http_code, parsed)
: g_strdup_printf("HTTP %ld: %s", http_code,
response->data && strlen(response->data) > 0 ? response->data : "Unknown error");
auto_gchar gchar* error_msg = g_strdup_printf("HTTP %ld: %s", http_code,
response->data ? response->data : "Unknown error");
log_error("Request HTTP error for '%s': %s", provider->name, error_msg);
_aiwin_display_error(user_data, error_msg);
} else {
@@ -739,10 +638,6 @@ _ai_generic_request_thread(gpointer data)
return NULL;
}
/* Keep the provider alive for the duration of this request. Without this, /ai remove provider X could free the provider
* struct (via the hash table's destroy-notify) while curl_easy_perform is still using it — classic UAF. */
ai_provider_ref(provider);
CURL* curl = curl_easy_init();
if (!curl) {
log_error("Failed to initialize curl for %s", req->provider_name);
@@ -773,10 +668,6 @@ _ai_generic_request_thread(gpointer data)
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
g_free(response.data);
/* Release the reference taken after lookup — provider may now be freed. */
ai_provider_unref(provider);
g_free(req->provider_name);
g_free(req->request_url);
g_free(req);
@@ -1085,18 +976,9 @@ _models_response_handler(AIProvider* provider, const gchar* response, gpointer u
{
_parse_and_cache_models(provider, response);
gint count = g_list_length(provider->models);
GString* msg = g_string_new(NULL);
g_string_append_printf(msg, "Cached %d models for provider '%s':", count, provider->name);
for (GList* curr = provider->models; curr; curr = g_list_next(curr)) {
g_string_append_printf(msg, "\n %s", (const gchar*)curr->data);
}
if (count > 0) {
g_string_append_printf(msg, "\nUse '/ai start %s <model>' to start a chat.", provider->name);
}
auto_gchar gchar* out = g_string_free(msg, FALSE);
_aiwin_display_response(user_data, out);
auto_gchar gchar* msg = g_strdup_printf("Cached %d models for provider '%s'. Use '/ai start <provider> <model>' to start a chat.",
g_list_length(provider->models), provider->name);
_aiwin_display_response(user_data, msg);
}
static gpointer
@@ -1168,11 +1050,6 @@ ai_session_create(const gchar* provider_name, const gchar* model)
}
AISession* session = g_new0(AISession, 1);
if (pthread_mutex_init(&session->lock, NULL) != 0) {
log_error("Failed to initialize session mutex");
g_free(session);
return NULL;
}
session->provider_name = g_strdup(provider_name);
session->provider = ai_provider_ref(provider);
session->model = g_strdup(model);
@@ -1218,8 +1095,6 @@ ai_session_unref(AISession* session)
}
g_list_free(session->history);
pthread_mutex_destroy(&session->lock);
g_free(session);
log_debug("AI session destroyed");
}
@@ -1230,12 +1105,10 @@ ai_session_add_message(AISession* session, const gchar* role, const gchar* conte
if (!session || !role || !content)
return;
pthread_mutex_lock(&session->lock);
AIMessage* msg = g_new0(AIMessage, 1);
msg->role = g_strdup(role);
msg->content = g_strdup(content);
session->history = g_list_append(session->history, msg);
pthread_mutex_unlock(&session->lock);
log_debug("Added %s message to session (total: %d)", role, g_list_length(session->history));
}
@@ -1246,7 +1119,6 @@ ai_session_clear_history(AISession* session)
if (!session)
return;
pthread_mutex_lock(&session->lock);
GList* curr = session->history;
while (curr) {
AIMessage* msg = curr->data;
@@ -1257,7 +1129,6 @@ ai_session_clear_history(AISession* session)
}
g_list_free(session->history);
session->history = NULL;
pthread_mutex_unlock(&session->lock);
log_info("AI session history cleared");
}
@@ -1276,64 +1147,24 @@ ai_session_set_model(AISession* session, const gchar* model)
if (!session || !model)
return;
pthread_mutex_lock(&session->lock);
g_free(session->model);
session->model = g_strdup(model);
pthread_mutex_unlock(&session->lock);
log_info("Session model changed to: %s", model);
}
void
ai_session_switch(AISession* session, const gchar* provider_name,
const gchar* model, gchar* api_key)
{
if (!session || !provider_name || !model || !api_key)
return;
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
log_warning("Provider '%s' not found for session switch", provider_name);
g_free(api_key);
return;
}
pthread_mutex_lock(&session->lock);
g_free(session->provider_name);
session->provider_name = g_strdup(provider_name);
ai_provider_unref(session->provider);
session->provider = ai_provider_ref(provider);
g_free(session->model);
session->model = g_strdup(model);
g_free(session->api_key);
session->api_key = g_strdup(api_key);
pthread_mutex_unlock(&session->lock);
g_free(api_key);
log_info("Session switched to %s/%s", provider_name, model);
}
/* ========================================================================
* API Request Handling
* ======================================================================== */
/**
* Build JSON payload from a GList of AIMessage nodes and a prompt string.
* This is the thread-safe variant used by _ai_request_thread() which holds
* the session lock while calling it.
*
* @param model The model identifier
* @param history GList of AIMessage* (must be held under session lock)
* @param prompt The new user prompt to append
* @return Newly allocated JSON string (caller must free)
*/
static gchar*
_build_json_payload_from_list(const gchar* model, GList* history, const gchar* prompt)
_build_json_payload(AISession* session, const gchar* prompt)
{
/* OpenAI-compatible Responses API format:
* {"model": "...", "input": [...], "stream": false, "store": false}
* store:false prevents providers from storing/using requests for training */
GString* messages_json = g_string_new("");
GList* curr = history;
GList* curr = session->history;
while (curr) {
AIMessage* msg = curr->data;
@@ -1356,7 +1187,7 @@ _build_json_payload_from_list(const gchar* model, GList* history, const gchar* p
}
g_string_append_printf(messages_json, "{\"role\":\"user\",\"content\":\"%s\"}", escaped_prompt);
auto_gchar gchar* escaped_model = ai_json_escape(model);
auto_gchar gchar* escaped_model = ai_json_escape(session->model);
gchar* json_payload = g_strdup_printf(
"{\"model\":\"%s\",\"input\":[%s],\"stream\":false,\"store\":false}",
escaped_model, messages_json->str);
@@ -1365,36 +1196,145 @@ _build_json_payload_from_list(const gchar* model, GList* history, const gchar* p
return json_payload;
}
gchar*
ai_parse_response(const gchar* response_json)
static gchar*
_parse_ai_response(const gchar* response_json)
{
if (!response_json || strlen(response_json) == 0)
return NULL;
/* Perplexity /v1/agent: {"output":[{"content":[{"text":"...","type":"output_text"}]}]}
* Legacy OpenAI: {"choices":[{"message":{"content":"..."}}]} */
gchar* text = _extract_json_string(response_json, "text");
if (text)
return text;
/* Try Perplexity /v1/agent format first: look for "text":"..." inside output array
* Response: {"output":[{"content":[{"text":"...","type":"output_text"}]}]} */
const gchar* text_start = strstr(response_json, "\"text\":\"");
if (text_start) {
text_start += strlen("\"text\":\"");
const gchar* p = text_start;
while (*p) {
if (*p == '\\' && *(p + 1) == '"') {
p += 2;
continue;
}
if (*p == '"') {
gsize len = p - text_start;
gchar* result = g_new0(gchar, len + 1);
gchar* out = result;
const gchar* in = text_start;
while (in < p) {
if (*in == '\\' && *(in + 1) == '"') {
*out++ = '"';
in += 2;
} else if (*in == '\\' && *(in + 1) == 'n') {
*out++ = '\n';
in += 2;
} else {
*out++ = *in++;
}
}
*out = '\0';
return result;
}
p++;
}
}
return _extract_json_string(response_json, "content");
/* Try legacy OpenAI format: "content":"..."
* Response: {"choices":[{"message":{"content":"..."}}]} */
const gchar* content_start = strstr(response_json, "\"content\":\"");
if (!content_start)
return NULL;
content_start += strlen("\"content\":\"");
/* Find the closing quote, accounting for escaped quotes */
const gchar* p = content_start;
while (*p) {
if (*p == '\\' && *(p + 1) == '"') {
/* Escaped quote, skip both characters */
p += 2;
continue;
}
if (*p == '"') {
/* Found unescaped closing quote */
gsize len = p - content_start;
/* Unescape the content: convert \" back to " */
gchar* result = g_new0(gchar, len + 1);
gchar* out = result;
const gchar* in = content_start;
while (in < p) {
if (*in == '\\' && *(in + 1) == '"') {
*out++ = '"';
in += 2;
} else if (*in == '\\' && *(in + 1) == 'n') {
*out++ = '\n';
in += 2;
} else {
*out++ = *in++;
}
}
*out = '\0';
return result;
}
p++;
}
return NULL;
}
/* Extract the human-readable error.message from a provider error envelope:
* {"error":{"message":"...","type":"...","code":...}}
* Scoped to the substring after "error": so unrelated top-level "message"
* fields do not get picked up. */
gchar*
ai_parse_error_message(const gchar* error_json)
/**
* Extract error message from an API error JSON response.
* Handles format: {"error":{"message":"...","type":"...","code":...}}
*/
static gchar*
_parse_error_response(const gchar* error_json)
{
if (!error_json || strlen(error_json) == 0)
return NULL;
/* Look for "message":"..." inside "error" object */
const gchar* error_obj = strstr(error_json, "\"error\":");
if (!error_obj)
return NULL;
return _extract_json_string(error_obj, "message");
const gchar* message_key = strstr(error_obj, "\"message\":\"");
if (!message_key)
return NULL;
message_key += strlen("\"message\":\"");
const gchar* msg_start = message_key;
const gchar* p = msg_start;
while (*p) {
if (*p == '\\' && *(p + 1) == '"') {
p += 2;
continue;
}
if (*p == '"') {
gsize len = p - msg_start;
gchar* result = g_new0(gchar, len + 1);
gchar* out = result;
const gchar* in = msg_start;
while (in < p) {
if (*in == '\\' && *(in + 1) == '"') {
*out++ = '"';
in += 2;
} else if (*in == '\\' && *(in + 1) == 'n') {
*out++ = '\n';
in += 2;
} else if (*in == '\\' && *(in + 1) == '\\') {
*out++ = '\\';
in += 2;
} else if (*in == '\\' && *(in + 1) == 't') {
*out++ = '\t';
in += 2;
} else {
*out++ = *in++;
}
}
*out = '\0';
return result;
}
p++;
}
return NULL;
}
static gpointer
@@ -1408,34 +1348,15 @@ _ai_request_thread(gpointer data)
auto_gchar gchar* prompt = args[1];
gpointer user_data = args[2];
/* =====================================================================
* CRITICAL: Copy all session state under lock to prevent UAF races with
* cmd_ai_switch() and ai_session_clear_history() which mutate session
* fields on the main thread without holding a session ref.
*
* The worker holds a ref to the session (via ai_session_ref in
* ai_send_prompt), which prevents the session struct from being freed,
* but does NOT prevent field mutations (g_free/g_strdup of strings,
* list modifications). We must snapshot all fields atomically.
* ===================================================================== */
pthread_mutex_lock(&session->lock);
auto_gchar gchar* local_provider_name = g_strdup(session->provider_name);
AIProvider* local_provider = ai_provider_ref(session->provider);
auto_gchar gchar* local_model = g_strdup(session->model);
auto_gchar gchar* local_api_key = g_strdup(session->api_key ? session->api_key : "");
pthread_mutex_unlock(&session->lock);
log_debug("[AI-THREAD] Session: %s/%s", local_provider_name, local_model);
log_debug("[AI-THREAD] API key length: %zu", strlen(local_api_key));
log_debug("[AI-THREAD] Session: %s/%s", session->provider_name, session->model);
log_debug("[AI-THREAD] API key length: %zu", session->api_key ? strlen(session->api_key) : 0);
/* Check for API key first */
if (!local_api_key || strlen(local_api_key) == 0) {
if (!session->api_key || strlen(session->api_key) == 0) {
auto_gchar gchar* error_msg = g_strdup_printf("No API key set for provider '%s'. Use '/ai set token %s <key>' to configure.",
local_provider_name, local_provider_name);
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
session->provider_name, session->provider_name);
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
_aiwin_display_error(user_data, error_msg);
ai_provider_unref(local_provider);
ai_session_unref(session);
g_free(args);
return NULL;
}
@@ -1444,30 +1365,23 @@ _ai_request_thread(gpointer data)
log_debug("[AI-THREAD] Curl initialized: %s", curl ? "OK" : "FAILED");
if (!curl) {
log_error("AI request failed for %s/%s: Failed to initialize curl",
local_provider_name, local_model);
session->provider_name, session->model);
_aiwin_display_error(user_data, "Failed to initialize curl.");
ai_provider_unref(local_provider);
ai_session_unref(session);
g_free(args);
return NULL;
}
/* Build JSON payload from history + prompt (under lock), then add user message to history */
log_debug("[AI-THREAD] Building JSON payload...");
pthread_mutex_lock(&session->lock);
auto_gchar gchar* json_payload = _build_json_payload_from_list(local_model, session->history, prompt);
/* Add user message to history now (it's in JSON but not yet in history) */
AIMessage* user_msg = g_new0(AIMessage, 1);
user_msg->role = g_strdup("user");
user_msg->content = g_strdup(prompt);
session->history = g_list_append(session->history, user_msg);
pthread_mutex_unlock(&session->lock);
/* Add user message to history FIRST */
ai_session_add_message(session, "user", prompt);
log_debug("[AI-THREAD] Added user message to history");
/* Build JSON payload (includes the message we just added) */
log_debug("[AI-THREAD] Building JSON payload...");
auto_gchar gchar* json_payload = _build_json_payload(session, prompt);
log_debug("[AI-THREAD] JSON payload: %s", json_payload);
/* Build headers using shared helper */
struct curl_slist* headers = _build_curl_headers(local_provider, local_api_key);
struct curl_slist* headers = _build_curl_headers(session->provider, session->api_key);
/* Response buffer */
struct curl_response_t response;
@@ -1475,11 +1389,11 @@ _ai_request_thread(gpointer data)
response.size = 0;
/* Build request URL */
const gchar* api_url = local_provider->api_url;
const gchar* api_url = session->provider ? session->provider->api_url : DEFAULT_OPENAI_URL;
auto_gchar gchar* request_url = g_strdup_printf("%s%sv1/responses", api_url, g_str_has_suffix(api_url, "/") ? "" : "/");
log_debug("[AI-THREAD] API URL: %s", api_url);
log_debug("[AI-THREAD] API Request URL: %s", request_url);
log_debug("[AI-THREAD] Model: %s", local_model);
log_debug("[AI-THREAD] Model: %s", session->model);
/* Set URL and execute request */
curl_easy_setopt(curl, CURLOPT_URL, request_url);
@@ -1496,36 +1410,33 @@ _ai_request_thread(gpointer data)
long http_code = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
log_debug("[AI-THREAD] HTTP response code: %ld", http_code);
auto_gchar gchar* response_data = g_steal_pointer(&response.data);
if (res != CURLE_OK) {
auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res));
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
_aiwin_display_error(user_data, error_msg);
} else if (http_code >= 400) {
/* Handle HTTP errors */
log_debug("[AI-THREAD] HTTP error response body (%zu bytes): %s",
response.size, response_data);
response.size, response.data ? response.data : "NULL");
/* Try to extract the actual error message from the JSON response */
auto_gchar gchar* parsed_error = ai_parse_error_message(response_data);
auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", http_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", http_code, response_data && strlen(response_data) > 0 ? response_data : "Unknown error");
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
auto_gchar gchar* parsed_error = _parse_error_response(response.data);
auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", http_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", http_code, response.data && strlen(response.data) > 0 ? response.data : "Unknown error");
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
_aiwin_display_error(user_data, error_msg);
} else {
log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, response_data);
auto_gchar gchar* content = ai_parse_response(response_data);
/* Parse response - transfer ownership to auto_gchar for cleanup */
log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, response.data ? response.data : "NULL");
auto_gchar gchar* response_data = response.data;
response.data = NULL;
auto_gchar gchar* content = _parse_ai_response(response_data);
if (content) {
/* Add assistant response to history (under lock) */
pthread_mutex_lock(&session->lock);
AIMessage* assistant_msg = g_new0(AIMessage, 1);
assistant_msg->role = g_strdup("assistant");
assistant_msg->content = g_strdup(content);
session->history = g_list_append(session->history, assistant_msg);
pthread_mutex_unlock(&session->lock);
/* Add assistant response to history */
ai_session_add_message(session, "assistant", content);
_aiwin_display_response(user_data, content);
} else {
log_error("AI response parse failed for %s/%s: %.200s...",
local_provider_name, local_model, response_data);
session->provider_name, session->model, response_data);
_aiwin_display_error(user_data, "Failed to parse AI response.");
}
}
@@ -1533,9 +1444,6 @@ _ai_request_thread(gpointer data)
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
ai_provider_unref(local_provider);
ai_session_unref(session);
g_free(args);
return NULL;

View File

@@ -19,6 +19,7 @@ typedef struct ai_provider_t
{
gchar* name; /* Provider name (e.g., "openai", "perplexity") */
gchar* api_url; /* API endpoint URL */
gchar* org_id; /* Optional organization ID */
gchar* project_id; /* Optional project ID (for some providers) */
gchar* default_model; /* Default model for this provider */
GHashTable* settings; /* Extensible per-provider settings (e.g., tools=enabled, search=disabled) */
@@ -32,7 +33,6 @@ typedef struct ai_provider_t
*/
typedef struct ai_session_t
{
pthread_mutex_t lock; /* Protects all session fields below */
gchar* provider_name; /* Provider name */
AIProvider* provider; /* Provider configuration */
gchar* model; /* Model identifier (e.g., "gpt-4", "sonar") */
@@ -66,9 +66,10 @@ AIProvider* ai_get_provider(const gchar* name);
* Add or update a provider configuration.
* @param name The provider name
* @param api_url The API endpoint URL
* @param org_id Optional organization ID (can be NULL)
* @return New AIProvider* (caller must unref when done)
*/
AIProvider* ai_add_provider(const gchar* name, const gchar* api_url);
AIProvider* ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id);
/**
* Remove a provider by name.
@@ -77,13 +78,6 @@ AIProvider* ai_add_provider(const gchar* name, const gchar* api_url);
*/
gboolean ai_remove_provider(const gchar* name);
/**
* Increment the reference count of a provider.
* @param provider The provider to reference
* @return The same provider pointer
*/
AIProvider* ai_provider_ref(AIProvider* provider);
/**
* Decrement the reference count of a provider.
* @param provider The provider to unreference
@@ -107,10 +101,13 @@ GList* ai_list_providers(void);
gchar* ai_providers_find(const char* const search_str, gboolean previous, void* context);
/**
* Reset the provider autocomplete state.
* Called from cmd_ac_reset() to clear autocomplete state.
* Find a model name for autocomplete.
* @param search_str The search string
* @param previous Whether to go to previous match
* @param context ProfAiWin* for the current session
* @return Model name, or NULL if not found
*/
void ai_providers_reset_ac(void);
gchar* ai_models_find(const char* const search_str, gboolean previous, void* context);
/**
* Get the API key for a provider.
@@ -172,7 +169,7 @@ gboolean ai_fetch_models(const gchar* provider_name, gpointer user_data);
gboolean ai_models_are_fresh(const gchar* provider_name);
/* ========================================================================
* Parsing helpers (exposed for testing)
* Model Parsing (for testing)
* ======================================================================== */
/**
@@ -183,24 +180,6 @@ gboolean ai_models_are_fresh(const gchar* provider_name);
*/
void ai_parse_models_from_json(AIProvider* provider, const gchar* json);
/**
* Extract assistant content from an LLM response body. Tries Perplexity
* /v1/responses "text" first, falls back to OpenAI "content". Decodes
* the \" and \n escape sequences only.
* @param response_json The JSON body from the provider
* @return Newly allocated content string, or NULL if not found (caller frees)
*/
gchar* ai_parse_response(const gchar* response_json);
/**
* Extract human-readable error message from an API error envelope.
* Expected format: {"error":{"message":"...","type":"...","code":...}}
* Decodes \" \n \\ \t escapes.
* @param error_json The JSON body of the error response
* @return Newly allocated error message, or NULL if not parseable (caller frees)
*/
gchar* ai_parse_error_message(const gchar* error_json);
/* ========================================================================
* Session Management
* ======================================================================== */
@@ -254,19 +233,6 @@ const gchar* ai_session_get_model(AISession* session);
*/
void ai_session_set_model(AISession* session, const gchar* model);
/**
* Atomically switch session provider, model, and API key.
* All mutations happen under the session lock to prevent races with
* _ai_request_thread() which snapshots session state before making requests.
*
* @param session The session
* @param provider_name New provider name
* @param model New model identifier
* @param api_key New API key (caller must free after calling this)
*/
void ai_session_switch(AISession* session, const gchar* provider_name,
const gchar* model, gchar* api_key);
/* ========================================================================
* Request Handling
* ======================================================================== */

View File

@@ -70,6 +70,7 @@
static char* _sub_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _notify_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* prefix);
static char* _theme_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _autoaway_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _autoconnect_autocomplete(ProfWin* window, const char* const input, gboolean previous);
@@ -468,8 +469,7 @@ static Autocomplete* all_acs[] = {
&ai_subcommands_ac,
&ai_set_subcommands_ac,
&ai_set_custom_subcommands_ac,
&ai_remove_subcommands_ac,
&ai_models_ac
&ai_remove_subcommands_ac
};
static GHashTable* ac_funcs = NULL;
@@ -1192,6 +1192,7 @@ cmd_ac_init(void)
autocomplete_add_unsorted(ai_set_subcommands_ac, "provider", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "token", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "default-provider", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "default-model", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "custom", FALSE);
@@ -1696,7 +1697,6 @@ cmd_ac_reset(ProfWin* window)
win_reset_search_attempts();
win_close_reset_search_attempts();
plugins_reset_autocomplete();
ai_providers_reset_ac();
}
void
@@ -4487,10 +4487,6 @@ _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolea
return result;
}
/* Forward declaration */
static char* _ai_provider_and_model_autocomplete(ProfWin* window, const char* const input,
gboolean previous, const char* cmd);
static char*
_ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
{
@@ -4499,6 +4495,7 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
/* Parse once for reuse - /ai <subcommand> [<arg1>] [<arg2>] */
gboolean parse_result = FALSE;
auto_gcharv gchar** args = parse_args(input, 1, 4, &parse_result);
gboolean space_at_end = g_str_has_suffix(input, " ");
int num_args = g_strv_length(args);
/* Top-level /ai <subcommand> autocomplete (e.g., /ai s<tab> -> /ai set) */
@@ -4513,6 +4510,12 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
return result;
}
// /ai set default-provider <provider> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set default-provider", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set default-model <provider> <model> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set default-model", ai_providers_find, previous, NULL);
if (result) {
@@ -4554,20 +4557,34 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
return result;
}
// /ai start <provider> <model> - autocomplete provider names and model names
result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai start");
// /ai start [<provider>] [<model>] - use shared parse_args
if (parse_result && args && args[0] != NULL && g_strcmp0(args[0], "start") == 0) {
/* args[0]="start", args[1]=provider (if typed), args[2]=model (if typed) */
if (num_args == 1 || (num_args == 2 && !space_at_end)) {
result = autocomplete_param_with_func(input, "/ai start", ai_providers_find, previous, NULL);
} else {
/* /ai start <provider> <model> - model autocomplete */
result = _ai_model_autocomplete(window, input, previous, "/ai start ");
}
if (result) {
return result;
}
}
// /ai switch <provider> <model> - autocomplete model names for specified provider
result = _ai_model_autocomplete(window, input, previous, "/ai switch");
if (result) {
return result;
}
// /ai switch <provider> <model> - autocomplete provider names and model names
result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai switch");
result = autocomplete_param_with_func(input, "/ai switch", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set default-model <provider> <model> - autocomplete provider names and model names
result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai set default-model");
// /ai switch <model> - autocomplete model names from current session's provider
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
result = autocomplete_param_with_func(input, "/ai switch", ai_models_find, previous, aiwin);
if (result) {
return result;
}
@@ -4587,26 +4604,13 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
}
/**
* Autocomplete provider names and model names for /ai <cmd> <provider> <model> patterns.
* First tries to autocomplete provider names, then model names if provider is valid.
* Autocomplete model names for /ai <cmd> <provider> <model> patterns.
* Extracts provider from input and provides model completions from that provider's cached models.
* Uses static ai_models_ac to preserve cycling state (last_found) across calls.
* The cmd_prefix should NOT include trailing space (e.g., "/ai start" not "/ai start ").
*/
static char*
_ai_provider_and_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd)
_ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd_prefix)
{
char* result;
/* First, try provider name autocomplete */
result = autocomplete_param_with_func(input, cmd, ai_providers_find, previous, NULL);
if (result) {
return result;
}
/* Provider was specified, try model name autocomplete */
/* Build the full command prefix with space */
auto_gchar gchar* cmd_prefix = g_strdup_printf("%s ", cmd);
if (!g_str_has_prefix(input, cmd_prefix)) {
return NULL;
}
@@ -4642,6 +4646,6 @@ _ai_provider_and_model_autocomplete(ProfWin* window, const char* const input, gb
auto_gchar gchar* full_prefix = g_strdup_printf("%s%s", cmd_prefix, rest);
result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous);
char* result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous);
return result;
}

View File

@@ -2804,6 +2804,7 @@ static const struct cmd_t command_defs[] = {
"/ai",
"/ai set provider <name> <url>",
"/ai set token <provider> <token>",
"/ai set default-provider <provider>",
"/ai set default-model <provider> <model>",
"/ai set custom <provider> <setting> <value>",
"/ai remove provider <name>",
@@ -2822,12 +2823,14 @@ static const struct cmd_t command_defs[] = {
{ "", "Display current AI settings and configured providers" },
{ "set provider <name> <url>", "Add or update a provider with custom API endpoint" },
{ "set token <provider> <token>", "Set API token for a provider (e.g., openai, perplexity)" },
{ "set default-provider <provider>", "Set global default provider for /ai start" },
{ "set default-model <provider> <model>", "Set default model for a provider" },
{ "set custom <provider> <setting> <value>", "Set provider-level setting (e.g., tools, search)" },
{ "remove provider <name>", "Remove a custom provider" },
{ "providers", "List configured providers with full details" },
{ "start <provider> [<model>]", "Start new AI chat (space-separated, uses defaults if omitted)" },
{ "start [<provider>] [<model>]", "Start new AI chat (space-separated, uses defaults if omitted)" },
{ "switch <provider> [<model>]", "Switch to different provider (and optionally model)" },
{ "switch <model>", "Change model in current session (keeps provider)" },
{ "models <provider>", "Fetch and display available models for provider" },
{ "clear", "Clear current chat history" })
CMD_EXAMPLES(
@@ -2835,6 +2838,7 @@ static const struct cmd_t command_defs[] = {
"/ai set token openai sk-xxx",
"/ai set token perplexity pplx-xxx",
"/ai set provider custom https://my-api.com/v1/chat/completions",
"/ai set default-provider perplexity",
"/ai set default-model perplexity sonar",
"/ai set custom perplexity tools enabled",
"/ai remove provider custom",

View File

@@ -8516,8 +8516,7 @@ _cmd_execute_default(ProfWin* window, const char* inp)
case WIN_AI:
{
ProfAiWin* aiwin = (ProfAiWin*)window;
auto_gchar gchar* stanza_id = connection_create_stanza_id();
cl_ev_send_ai_msg(aiwin, inp, stanza_id);
cl_ev_send_ai_msg(aiwin, inp, NULL);
break;
}
default:
@@ -10818,6 +10817,7 @@ cmd_ai(ProfWin* window, const char* const command, gchar** args)
cons_show("Use '/ai start' to begin a chat (uses default provider/model).");
cons_show("Use '/ai models <provider>' to fetch available models.");
cons_show("Available models: https://models.litellm.ai/");
return TRUE;
}
@@ -10839,7 +10839,7 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
cons_bad_cmd_usage(command);
return TRUE;
}
if (ai_add_provider(args[2], args[3])) {
if (ai_add_provider(args[2], args[3], NULL)) {
cons_show("Provider '%s' configured with URL: %s", args[2], args[3]);
} else {
cons_show_error("Failed to configure provider '%s'.", args[2]);
@@ -10876,6 +10876,23 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
cons_show("Setting '%s' for provider '%s' set to: %s", args[3], args[2], args[4]);
cons_show("");
return TRUE;
} else if (g_strcmp0(args[1], "default-provider") == 0) {
// /ai set default-provider <provider>
if (g_strv_length(args) < 3) {
cons_bad_cmd_usage(command);
return TRUE;
}
const gchar* provider_name = args[2];
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
cons_show_error("Provider '%s' not found. Use '/ai set provider %s <url>' to add it.",
provider_name, provider_name);
return TRUE;
}
prefs_set_string(PREF_AI_PROVIDER, provider_name);
cons_show("Default provider set to: %s", provider_name);
cons_show("");
return TRUE;
}
cons_bad_cmd_usage(command);
@@ -10944,11 +10961,8 @@ cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
if (!model) {
model = ai_get_provider_default_model(provider_name);
}
if (!model) {
cons_show_error("No model specified and no default model set for provider '%s'.", provider_name);
cons_show("Use '/ai set default-model %s <model>'.", provider_name);
return TRUE;
model = "gpt-4o"; // Fallback
}
// Check for API key
@@ -10981,9 +10995,6 @@ cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
}
log_debug("[AI-CMD] wins_new_ai() returned successfully, window type: %d", ai_win->type);
// Release the reference held by cmd_ai_start, since the window now holds one
ai_session_unref(session);
// Add welcome message to the AI window
log_debug("[AI-CMD] Adding welcome messages...");
win_println(ai_win, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model);
@@ -11013,12 +11024,12 @@ cmd_ai_model(ProfWin* window, const char* const command, gchar** args)
const gchar* model = args[1];
// Must be in an AI window to use this command
if (!window || window->type != WIN_AI) {
cons_show_error("Must be in an AI chat window to use this command.");
// Get current AI window
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
if (!aiwin) {
cons_show("No active AI chat window. Use '/ai start' first.");
return TRUE;
}
ProfAiWin* aiwin = (ProfAiWin*)window;
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
@@ -11044,12 +11055,12 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
return TRUE;
}
// Must be in an AI window to use this command
if (!window || window->type != WIN_AI) {
cons_show_error("Must be in an AI chat window to use this command.");
// Get current AI window
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
if (!aiwin) {
cons_show("No active AI chat window. Use '/ai start' first.");
return TRUE;
}
ProfAiWin* aiwin = (ProfAiWin*)window;
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
@@ -11063,12 +11074,14 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
const gchar* provider_name;
const gchar* model;
gboolean changed_provider = FALSE;
// Check if arg1 is a known provider
AIProvider* provider = ai_get_provider(arg1);
if (provider) {
// arg1 is a provider name
provider_name = arg1;
changed_provider = TRUE;
// Get model: arg2 > provider default > error
if (arg2) {
@@ -11084,10 +11097,6 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
} else {
// arg1 is a model name, keep current provider
provider_name = aiwin->session->provider_name;
if (!provider_name) {
cons_show_error("Session has no provider name.");
return TRUE;
}
model = arg1;
}
@@ -11099,8 +11108,19 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
return TRUE;
}
// Atomically switch session provider, model, and API key (thread-safe)
ai_session_switch(aiwin->session, provider_name, model, g_steal_pointer(&api_key));
// Update the existing session's provider and model
if (changed_provider) {
AIProvider* new_provider = ai_get_provider(provider_name);
g_free(aiwin->session->provider_name);
aiwin->session->provider_name = g_strdup(provider_name);
ai_provider_unref(aiwin->session->provider);
aiwin->session->provider = new_provider;
aiwin->session->provider->ref_count++;
}
g_free(aiwin->session->model);
aiwin->session->model = g_strdup(model);
g_free(aiwin->session->api_key);
aiwin->session->api_key = g_strdup(api_key);
// Update window title
win_println((ProfWin*)aiwin, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model);
@@ -11147,7 +11167,8 @@ cmd_ai_models(ProfWin* window, const char* const command, gchar** args)
cons_show("Use '/ai models %s' to fetch fresh models from the API.", provider_name);
} else {
// Default: fetch fresh models from API
ai_fetch_models(provider_name, window);
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
ai_fetch_models(provider_name, aiwin);
}
return TRUE;
@@ -11157,44 +11178,103 @@ gboolean
cmd_ai_clear(ProfWin* window, const char* const command, gchar** args)
{
// /ai clear
// Must be in an AI window to use this command
if (!window || window->type != WIN_AI) {
cons_show_error("Must be in an AI chat window to use this command.");
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
if (aiwin) {
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
if (aiwin->session) {
ai_session_clear_history(aiwin->session);
}
cons_show("Chat history cleared.");
} else {
cons_show("No active AI chat window to clear.");
}
cons_show("");
return TRUE;
}
gboolean
cmd_ai_correct(ProfWin* window, const char* const command, gchar** args)
{
// /ai correct <message>
// Join all arguments from args[1] onwards to support multi-word prompts
auto_gchar gchar* prompt = g_strjoinv(" ", &args[1]);
if (prompt == NULL || strlen(prompt) == 0) {
cons_bad_cmd_usage(command);
return TRUE;
}
ProfAiWin* aiwin = (ProfAiWin*)window;
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
if (aiwin->session) {
ai_session_clear_history(aiwin->session);
// Get current AI window
ProfAiWin* aiwin = wins_get_ai();
if (!aiwin) {
cons_show("No active AI chat window. Use '/ai start <provider>/<model>' first.");
return TRUE;
}
win_clear(window);
cons_show("AI Chat history cleared.");
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
if (!aiwin->session) {
cons_show("No active session in this chat window.");
return TRUE;
}
// Get the last user message and replace it
GList* history = aiwin->session->history;
GList* last_user_msg = NULL;
for (GList* curr = history; curr; curr = g_list_next(curr)) {
AIMessage* msg = curr->data;
if (g_strcmp0(msg->role, "user") == 0) {
last_user_msg = curr;
}
}
if (!last_user_msg) {
cons_show("No user messages in this chat to correct.");
return TRUE;
}
// Replace the last user message
AIMessage* msg = last_user_msg->data;
g_free(msg->content);
msg->content = g_strdup(prompt);
// Resend the prompt
cons_show("Correcting message...");
ai_send_prompt(aiwin->session, prompt, aiwin);
return TRUE;
}
gboolean
cmd_ai_providers(ProfWin* window, const char* const command, gchar** args)
{
if (args[1] == NULL || g_strcmp0(args[1], "list") != 0) {
// List all available providers
cons_show("Available AI providers:");
cons_show("");
cons_show(" openai - OpenAI API (https://api.openai.com)");
cons_show(" perplexity - Perplexity API (https://api.perplexity.ai)");
cons_show("");
cons_show("Add custom providers with: /ai set provider <name> <url>");
return TRUE;
}
// List configured providers with key status
cons_show("Configured providers:");
cons_show("");
GList* providers = ai_list_providers();
if (!providers) {
cons_show(" (none configured)");
cons_show("");
cons_show("Add a provider with: /ai set provider <name> <url>");
return TRUE;
}
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
AIProvider* provider = curr->data;
auto_gchar gchar* key = ai_get_provider_key(provider->name);
gchar* key = ai_get_provider_key(provider->name);
cons_show(" %s", provider->name);
cons_show(" URL: %s", provider->api_url);
cons_show(" Key: %s", key ? "configured" : "NOT configured");
if (provider->org_id && strlen(provider->org_id) > 0) {
cons_show(" Org: %s", provider->org_id);
}
cons_show("");
g_free(key);
}
g_list_free(providers);

View File

@@ -67,8 +67,6 @@
#define PREF_GROUP_PLUGINS "plugins"
#define PREF_GROUP_EXECUTABLES "executables"
#define PREF_GROUP_AI "ai"
#define PREF_GROUP_AI_PREFIX "ai/" /* per-provider subsection prefix: [ai/<provider>] */
#define PREF_AI_SETTING_PREFIX "setting." /* per-provider custom setting key prefix */
#define INPBLOCK_DEFAULT 1000
@@ -245,69 +243,6 @@ _prefs_load(void)
g_key_file_remove_key(prefs, PREF_GROUP_UI, "titlebar.muc.title.jid", NULL);
g_key_file_remove_key(prefs, PREF_GROUP_UI, "titlebar.muc.title.name", NULL);
}
/* Migrate legacy flat [ai] layout
* openai_url=..., openai=<token>, openai_models=..., openai_default_model=...
* into per-provider subsections
* [ai/openai] url=..., key=..., models=..., default_model=...
* Idempotent: once the flat keys are gone, this is a no-op. */
if (g_key_file_has_group(prefs, PREF_GROUP_AI)) {
gsize key_count = 0;
auto_gcharv gchar** all_keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &key_count, NULL);
/* Collect provider names from <name>_url keys first so we can migrate
* the whole bundle (url + token + models + default_model) atomically
* per provider, without misclassifying unrelated flat keys. */
GHashTable* to_migrate = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
for (gsize i = 0; i < key_count; i++) {
const gchar* k = all_keys[i];
if (g_str_has_suffix(k, "_url") && !g_str_has_suffix(k, "_models_url")) {
gsize klen = strlen(k);
if (klen > 4) {
g_hash_table_add(to_migrate, g_strndup(k, klen - 4));
}
}
}
GList* provider_names = g_hash_table_get_keys(to_migrate);
for (GList* curr = provider_names; curr; curr = g_list_next(curr)) {
const gchar* name = (const gchar*)curr->data;
auto_gchar gchar* group = g_strconcat(PREF_GROUP_AI_PREFIX, name, NULL);
const struct
{
const gchar* flat_suffix;
const gchar* new_key;
} map[] = {
{ "_url", "url" },
{ "_models", "models" },
{ "_default_model", "default_model" },
{ NULL, NULL },
};
for (int j = 0; map[j].flat_suffix; j++) {
auto_gchar gchar* flat_key = g_strconcat(name, map[j].flat_suffix, NULL);
if (g_key_file_has_key(prefs, PREF_GROUP_AI, flat_key, NULL)) {
auto_gchar gchar* val = g_key_file_get_string(prefs, PREF_GROUP_AI, flat_key, NULL);
if (val) {
g_key_file_set_string(prefs, group, map[j].new_key, val);
}
g_key_file_remove_key(prefs, PREF_GROUP_AI, flat_key, NULL);
}
}
/* Token was stored as the bare provider name in [ai]. */
if (g_key_file_has_key(prefs, PREF_GROUP_AI, name, NULL)) {
auto_gchar gchar* val = g_key_file_get_string(prefs, PREF_GROUP_AI, name, NULL);
if (val) {
g_key_file_set_string(prefs, group, "key", val);
}
g_key_file_remove_key(prefs, PREF_GROUP_AI, name, NULL);
}
}
g_list_free(provider_names);
g_hash_table_destroy(to_migrate);
}
_save_prefs();
boolean_choice_ac = autocomplete_new();
@@ -1773,103 +1708,73 @@ _save_prefs(void)
}
/* ========================================================================
* AI provider preferences
*
* Each provider lives in its own [ai/<name>] subsection of the keyfile with:
* url=...
* key=<api token>
* default_model=...
* models=model1;model2;...
* setting.<custom>=value (e.g. setting.tools=enabled)
* The bare [ai] section keeps only the global "defaults_initialized" sentinel.
* Legacy flat-key layouts are migrated on first load in _prefs_load().
* AI Token Management
* ======================================================================== */
static gchar*
_ai_section(const char* const provider)
{
return g_strconcat(PREF_GROUP_AI_PREFIX, provider, NULL);
}
static gboolean
_ai_set_string(const char* const provider, const char* const key, const char* const value)
{
if (!provider || !key || !value || !prefs)
return FALSE;
auto_gchar gchar* group = _ai_section(provider);
g_key_file_set_string(prefs, group, key, value);
_save_prefs();
return TRUE;
}
static char*
_ai_get_string(const char* const provider, const char* const key)
{
if (!provider || !key || !prefs)
return NULL;
auto_gchar gchar* group = _ai_section(provider);
if (!g_key_file_has_key(prefs, group, key, NULL))
return NULL;
return g_key_file_get_string(prefs, group, key, NULL);
}
static gboolean
_ai_remove_string(const char* const provider, const char* const key)
{
if (!provider || !key || !prefs)
return FALSE;
auto_gchar gchar* group = _ai_section(provider);
if (!g_key_file_has_key(prefs, group, key, NULL))
return FALSE;
g_key_file_remove_key(prefs, group, key, NULL);
_save_prefs();
return TRUE;
}
/* --- Token --- */
gboolean
prefs_ai_set_token(const char* const provider, const char* const token)
{
if (!provider || !prefs)
if (!provider)
return FALSE;
if (!token)
if (!prefs)
return FALSE;
if (token) {
g_key_file_set_string(prefs, PREF_GROUP_AI, provider, token);
_save_prefs();
return TRUE;
} else {
return prefs_ai_remove_token(provider);
return _ai_set_string(provider, "key", token);
}
}
gboolean
prefs_ai_remove_token(const char* const provider)
{
return _ai_remove_string(provider, "key");
if (!provider)
return FALSE;
if (!prefs)
return FALSE;
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, provider, NULL)) {
return FALSE;
}
g_key_file_remove_key(prefs, PREF_GROUP_AI, provider, NULL);
_save_prefs();
return TRUE;
}
char*
prefs_ai_get_token(const char* const provider)
{
return _ai_get_string(provider, "key");
if (!provider || !prefs)
return NULL;
return g_key_file_get_string(prefs, PREF_GROUP_AI, provider, NULL);
}
GList*
prefs_ai_list_tokens(void)
{
if (!prefs)
if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) {
return NULL;
}
GList* result = NULL;
GList* providers = prefs_ai_list_providers();
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
const gchar* name = (const gchar*)curr->data;
auto_gchar gchar* tok = prefs_ai_get_token(name);
if (tok && tok[0] != '\0') {
gsize len;
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &len, NULL);
for (gsize i = 0; i < len; i++) {
char* name = keys[i];
auto_gchar gchar* value = g_key_file_get_string(prefs, PREF_GROUP_AI, name, NULL);
if (value) {
result = g_list_append(result, g_strdup(name));
}
}
prefs_free_ai_providers(providers);
return result;
}
@@ -1881,45 +1786,72 @@ prefs_free_ai_tokens(GList* tokens)
}
}
/* --- Models cache --- */
gboolean
prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count)
{
if (!provider || count <= 0 || !prefs)
if (!provider || count <= 0)
return FALSE;
if (!prefs)
return FALSE;
/* Build semicolon-separated model list */
GString* model_str = g_string_new("");
for (int i = 0; i < count; i++) {
if (i > 0)
if (i > 0) {
g_string_append_c(model_str, ';');
}
g_string_append(model_str, models[i]);
}
gboolean ok = _ai_set_string(provider, "models", model_str->str);
g_key_file_set_string(prefs, PREF_GROUP_AI, g_strconcat(provider, "_models", NULL), model_str->str);
g_string_free(model_str, TRUE);
return ok;
_save_prefs();
return TRUE;
}
gboolean
prefs_ai_remove_models(const char* const provider)
{
return _ai_remove_string(provider, "models");
if (!provider)
return FALSE;
if (!prefs)
return FALSE;
auto_gchar gchar* key = g_strconcat(provider, "_models", NULL);
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
return FALSE;
}
g_key_file_remove_key(prefs, PREF_GROUP_AI, key, NULL);
_save_prefs();
return TRUE;
}
GList*
prefs_ai_get_models(const char* const provider)
{
auto_gchar gchar* model_str = _ai_get_string(provider, "models");
if (!model_str || model_str[0] == '\0')
if (!provider || !prefs)
return NULL;
auto_gchar gchar* key = g_strconcat(provider, "_models", NULL);
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
return NULL;
}
auto_gchar gchar* model_str = g_key_file_get_string(prefs, PREF_GROUP_AI, key, NULL);
if (!model_str || strlen(model_str) == 0) {
return NULL;
}
GList* result = NULL;
gchar** models = g_strsplit(model_str, ";", -1);
for (int i = 0; models[i] != NULL; i++) {
result = g_list_append(result, g_strdup(models[i]));
}
g_strfreev(models);
return result;
}
@@ -1931,207 +1863,6 @@ prefs_free_ai_models(GList* models)
}
}
/* --- Default model --- */
gboolean
prefs_ai_set_default_model(const char* const provider, const char* const model)
{
if (!provider || !model || !prefs)
return FALSE;
if (model[0] == '\0')
return prefs_ai_remove_default_model(provider);
return _ai_set_string(provider, "default_model", model);
}
gboolean
prefs_ai_remove_default_model(const char* const provider)
{
return _ai_remove_string(provider, "default_model");
}
char*
prefs_ai_get_default_model(const char* const provider)
{
return _ai_get_string(provider, "default_model");
}
/* --- Provider URL --- */
gboolean
prefs_ai_set_provider(const char* const provider, const char* const url)
{
if (!provider || !url)
return FALSE;
return _ai_set_string(provider, "url", url);
}
gboolean
prefs_ai_remove_provider(const char* const provider)
{
/* Provider presence is identified by the url key; removing it is what
* makes prefs_ai_list_providers stop returning this name. The remaining
* sub-keys are wiped through prefs_ai_remove_section(). */
return _ai_remove_string(provider, "url");
}
char*
prefs_ai_get_provider_url(const char* const provider)
{
return _ai_get_string(provider, "url");
}
gboolean
prefs_ai_remove_section(const char* const provider)
{
if (!provider || !prefs)
return FALSE;
auto_gchar gchar* group = _ai_section(provider);
if (!g_key_file_has_group(prefs, group))
return FALSE;
g_key_file_remove_group(prefs, group, NULL);
_save_prefs();
return TRUE;
}
GList*
prefs_ai_list_providers(void)
{
if (!prefs)
return NULL;
GList* result = NULL;
gsize len = 0;
auto_gcharv gchar** groups = g_key_file_get_groups(prefs, &len);
if (!groups)
return NULL;
gsize prefix_len = strlen(PREF_GROUP_AI_PREFIX);
for (gsize i = 0; i < len; i++) {
const gchar* g = groups[i];
if (g_str_has_prefix(g, PREF_GROUP_AI_PREFIX) && strlen(g) > prefix_len) {
/* Only count groups that actually identify a provider (have url). */
if (g_key_file_has_key(prefs, g, "url", NULL)) {
result = g_list_append(result, g_strdup(g + prefix_len));
}
}
}
return result;
}
void
prefs_free_ai_providers(GList* providers)
{
if (providers) {
g_list_free_full(providers, g_free);
}
}
GList*
prefs_ai_get_providers(void)
{
GList* configured = prefs_ai_list_providers();
GList* result = NULL;
for (GList* curr = configured; curr; curr = g_list_next(curr)) {
const gchar* name = (const gchar*)curr->data;
auto_gchar gchar* url = prefs_ai_get_provider_url(name);
if (url && strlen(url) > 0) {
result = g_list_append(result, g_strdup(name));
result = g_list_append(result, g_strdup(url));
}
}
prefs_free_ai_providers(configured);
/* Seed openai/perplexity into the in-memory result on very first init
* only. In production the "defaults_initialized" sentinel in [ai] is what
* tells us we've already seeded — a user who removes every provider must
* not get them silently re-added on the next start. When prefs is NULL
* (e.g. unit tests that bring up ai_client without prefs_load) we still
* seed the in-memory list so behaviour matches a fresh first run, but
* skip the persistence write. */
gboolean already_seeded = prefs && g_key_file_get_boolean(prefs, PREF_GROUP_AI, "defaults_initialized", NULL);
if (!already_seeded && !result) {
if (prefs) {
prefs_ai_set_provider("openai", "https://api.openai.com/");
prefs_ai_set_provider("perplexity", "https://api.perplexity.ai/");
}
result = g_list_append(result, g_strdup("openai"));
result = g_list_append(result, g_strdup("https://api.openai.com/"));
result = g_list_append(result, g_strdup("perplexity"));
result = g_list_append(result, g_strdup("https://api.perplexity.ai/"));
}
if (prefs && !already_seeded) {
g_key_file_set_boolean(prefs, PREF_GROUP_AI, "defaults_initialized", TRUE);
_save_prefs();
}
return result;
}
/* --- Custom per-provider settings (setting.<name>) --- */
gboolean
prefs_ai_set_setting(const char* const provider, const char* const setting, const char* const value)
{
if (!provider || !setting)
return FALSE;
auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL);
if (!value)
return _ai_remove_string(provider, key);
return _ai_set_string(provider, key, value);
}
gboolean
prefs_ai_remove_setting(const char* const provider, const char* const setting)
{
if (!provider || !setting)
return FALSE;
auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL);
return _ai_remove_string(provider, key);
}
char*
prefs_ai_get_setting(const char* const provider, const char* const setting)
{
if (!provider || !setting)
return NULL;
auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL);
return _ai_get_string(provider, key);
}
GList*
prefs_ai_list_settings(const char* const provider)
{
if (!provider || !prefs)
return NULL;
auto_gchar gchar* group = _ai_section(provider);
if (!g_key_file_has_group(prefs, group))
return NULL;
GList* result = NULL;
gsize len = 0;
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, group, &len, NULL);
gsize prefix_len = strlen(PREF_AI_SETTING_PREFIX);
for (gsize i = 0; i < len; i++) {
if (g_str_has_prefix(keys[i], PREF_AI_SETTING_PREFIX) && strlen(keys[i]) > prefix_len) {
result = g_list_append(result, g_strdup(keys[i] + prefix_len));
}
}
return result;
}
void
prefs_free_ai_settings(GList* settings)
{
if (settings) {
g_list_free_full(settings, g_free);
}
}
// get the preference group for a specific preference
// for example the PREF_BEEP setting ("beep" in .profrc, see _get_key) belongs
// to the [ui] section.

View File

@@ -366,35 +366,10 @@ char* prefs_ai_get_token(const char* const provider);
GList* prefs_ai_list_tokens(void);
void prefs_free_ai_tokens(GList* tokens);
/* AI provider management */
gboolean prefs_ai_set_provider(const char* const provider, const char* const url);
gboolean prefs_ai_remove_provider(const char* const provider);
char* prefs_ai_get_provider_url(const char* const provider);
GList* prefs_ai_list_providers(void);
void prefs_free_ai_providers(GList* providers);
GList* prefs_ai_get_providers(void);
/* Wipe the whole [ai/<provider>] subsection (url, key, models, default_model,
* custom settings) in one call. */
gboolean prefs_ai_remove_section(const char* const provider);
/* AI model cache management */
gboolean prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count);
gboolean prefs_ai_remove_models(const char* const provider);
GList* prefs_ai_get_models(const char* const provider);
void prefs_free_ai_models(GList* models);
/* AI default model per provider */
gboolean prefs_ai_set_default_model(const char* const provider, const char* const model);
gboolean prefs_ai_remove_default_model(const char* const provider);
char* prefs_ai_get_default_model(const char* const provider);
/* AI per-provider custom settings (e.g. tools, search) — persisted as
* setting.<name>=<value> inside [ai/<provider>]. Pass NULL value to remove. */
gboolean prefs_ai_set_setting(const char* const provider, const char* const setting, const char* const value);
gboolean prefs_ai_remove_setting(const char* const provider, const char* const setting);
char* prefs_ai_get_setting(const char* const provider, const char* const setting);
GList* prefs_ai_list_settings(const char* const provider);
void prefs_free_ai_settings(GList* settings);
#endif

View File

@@ -61,22 +61,6 @@ integrity_issue_free(integrity_issue_t* issue)
}
}
gboolean
log_database_can_recover_messages(void)
{
if (!active_db_backend) {
return FALSE;
}
auto_gchar gchar* pref_dblog = prefs_get_string(PREF_DBLOG);
if (g_strcmp0(pref_dblog, "off") == 0) {
return FALSE;
}
if (g_strcmp0(pref_dblog, "redact") == 0) {
return FALSE;
}
return TRUE;
}
gboolean
log_database_init(ProfAccount* account)
{

View File

@@ -109,9 +109,6 @@ ProfMessage* log_database_get_limits_info(const gchar* const contact_barejid, gb
void log_database_close(void);
GSList* log_database_verify_integrity(const gchar* const contact_barejid);
// FALSE if no backend, or PREF_DBLOG is "off" / "redact".
gboolean log_database_can_recover_messages(void);
// Cross-backend export/import (requires HAVE_SQLITE)
#ifdef HAVE_SQLITE
int log_database_export_to_flatfile(const gchar* const contact_jid);

View File

@@ -211,13 +211,9 @@ _first_pass(FILE* fp, const char* basename,
if (pl->stanza_id && pl->stanza_id[0] != '\0') {
if (g_hash_table_contains(seen_stanza_ids, pl->stanza_id)) {
// Older clients (Pidgin, Adium, even early Profanity)
// sometimes reuse client-generated stanza-ids across
// distinct messages, so a collision is not a real
// integrity defect — log it for diagnostics but don't
// surface it through /history verify.
log_debug("flatfile verify: duplicate stanza-id \"%s\" at %s:%d",
pl->stanza_id, basename, lineno);
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id)));
} else {
g_hash_table_insert(seen_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
}
@@ -306,13 +302,7 @@ _verify_contact_dir(const char* cdir_path, GSList** issues)
return;
}
// Reconstruct the contact JID from the directory name so issue reports
// identify which contact the problem belongs to (every contact has its
// own history.log, so the bare filename is ambiguous).
auto_gchar gchar* dir_name = g_path_get_basename(cdir_path);
auto_gcharv gchar** parts = g_strsplit(dir_name, "_at_", -1);
auto_gchar gchar* contact_jid = g_strjoinv("@", parts);
auto_gchar gchar* basename = g_strdup_printf("%s/history.log", contact_jid);
const char* basename = "history.log";
_check_permissions(filepath, basename, issues);

View File

@@ -466,36 +466,23 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
sqlite3_finalize(stmt);
}
// Check timestamp ordering, restricted to consecutive rows belonging
// to the same conversation pair so adjacency does not span unrelated
// contacts. Adjacent rows by id from different conversations are
// legitimately out-of-order and should not be reported.
// Check timestamp ordering for a specific contact or all
const Jid* myjid = connection_get_jid();
if (myjid && myjid->barejid) {
auto_sqlite char* query = NULL;
if (contact_barejid) {
query = sqlite3_mprintf(
"SELECT A.`id`, A.`timestamp`, A.`from_jid`, A.`to_jid`, B.`id`, B.`timestamp` "
"FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`from_jid` = A.`from_jid` AND B.`to_jid` = A.`to_jid` "
" AND B.`id` > A.`id` "
"SELECT A.`id`, A.`timestamp`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
"WHERE A.`timestamp` > B.`timestamp` "
"AND ((A.`from_jid` = %Q AND A.`to_jid` = %Q) OR (A.`from_jid` = %Q AND A.`to_jid` = %Q)) "
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` C "
" WHERE C.`from_jid` = A.`from_jid` AND C.`to_jid` = A.`to_jid` "
" AND C.`id` > A.`id` AND C.`id` < B.`id`) "
"LIMIT 50",
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid);
} else {
query = sqlite3_mprintf(
"SELECT A.`id`, A.`timestamp`, A.`from_jid`, A.`to_jid`, B.`id`, B.`timestamp` "
"FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`from_jid` = A.`from_jid` AND B.`to_jid` = A.`to_jid` "
" AND B.`id` > A.`id` "
"SELECT A.`id`, A.`timestamp`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
"WHERE A.`timestamp` > B.`timestamp` "
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` C "
" WHERE C.`from_jid` = A.`from_jid` AND C.`to_jid` = A.`to_jid` "
" AND C.`id` > A.`id` AND C.`id` < B.`id`) "
"LIMIT 50");
}
@@ -503,19 +490,15 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
while (sqlite3_step(stmt) == SQLITE_ROW) {
int id_a = sqlite3_column_int(stmt, 0);
const char* ts_a = (const char*)sqlite3_column_text(stmt, 1);
const char* from_a = (const char*)sqlite3_column_text(stmt, 2);
const char* to_a = (const char*)sqlite3_column_text(stmt, 3);
int id_b = sqlite3_column_int(stmt, 4);
const char* ts_b = (const char*)sqlite3_column_text(stmt, 5);
int id_b = sqlite3_column_int(stmt, 2);
const char* ts_b = (const char*)sqlite3_column_text(stmt, 3);
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
issue->level = INTEGRITY_WARNING;
issue->file = g_strdup_printf("%s↔%s/chatlog.db",
from_a ? from_a : "?", to_a ? to_a : "?");
issue->file = g_strdup("chatlog.db");
issue->line = id_a;
issue->message = g_strdup_printf("Timestamp out of order: row %d (%s) > row %d (%s)",
id_a, ts_a ? ts_a : "NULL",
id_b, ts_b ? ts_b : "NULL");
id_a, ts_a ? ts_a : "NULL", id_b, ts_b ? ts_b : "NULL");
issues = g_slist_append(issues, issue);
}
sqlite3_finalize(stmt);
@@ -523,7 +506,7 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
// Check broken LMC references
auto_sqlite char* lmc_query = sqlite3_mprintf(
"SELECT A.`id`, A.`replaces_db_id`, A.`from_jid`, A.`to_jid` FROM `ChatLogs` A "
"SELECT A.`id`, A.`replaces_db_id` FROM `ChatLogs` A "
"WHERE A.`replaces_db_id` IS NOT NULL "
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` B WHERE B.`id` = A.`replaces_db_id`) "
"LIMIT 50");
@@ -532,13 +515,10 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
while (sqlite3_step(stmt) == SQLITE_ROW) {
int id = sqlite3_column_int(stmt, 0);
int replaces_id = sqlite3_column_int(stmt, 1);
const char* from = (const char*)sqlite3_column_text(stmt, 2);
const char* to = (const char*)sqlite3_column_text(stmt, 3);
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
issue->level = INTEGRITY_ERROR;
issue->file = g_strdup_printf("%s↔%s/chatlog.db",
from ? from : "?", to ? to : "?");
issue->file = g_strdup("chatlog.db");
issue->line = id;
issue->message = g_strdup_printf("Broken LMC reference: row %d references non-existent row %d",
id, replaces_id);

View File

@@ -301,6 +301,11 @@ cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const
return;
}
// Snap viewport to bottom so the user's own message is visible even if
// they had scrolled up to read history; otherwise win_is_paged() in
// _win_printf would suppress the print.
win_move_to_end(&aiwin->window);
// Display user message in AI window.
win_print_outgoing(&aiwin->window, ">>", id, NULL, message);

View File

@@ -260,7 +260,7 @@ autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean quote,
FREE_SET_NULL(ac->search_str);
}
ac->search_str = g_strdup(search_str);
ac->search_str = strdup(search_str);
found = _search(ac, ac->items, quote, NEXT);
return found;
@@ -305,7 +305,7 @@ autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean quote,
// autocomplete_func func is used -> autocomplete_param_with_func
// Autocomplete ac, gboolean quote are used -> autocomplete_param_with_ac
static char*
_autocomplete_param_common(const char* const input, const char* command, autocomplete_func func, Autocomplete ac, gboolean quote, gboolean previous, void* context)
_autocomplete_param_common(const char* const input, char* command, autocomplete_func func, Autocomplete ac, gboolean quote, gboolean previous, void* context)
{
int len;
auto_gchar gchar* command_cpy = g_strdup_printf("%s ", command);
@@ -344,7 +344,7 @@ _autocomplete_param_common(const char* const input, const char* command, autocom
}
char*
autocomplete_param_with_func(const char* const input, const char* command, autocomplete_func func, gboolean previous, void* context)
autocomplete_param_with_func(const char* const input, char* command, autocomplete_func func, gboolean previous, void* context)
{
return _autocomplete_param_common(input, command, func, NULL, FALSE, previous, context);
}

View File

@@ -63,7 +63,7 @@ gchar* autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean
GList* autocomplete_create_list(Autocomplete ac);
gint autocomplete_length(Autocomplete ac);
char* autocomplete_param_with_func(const char* const input, const char* command,
char* autocomplete_param_with_func(const char* const input, char* command,
autocomplete_func func, gboolean previous, void* context);
char* autocomplete_param_with_ac(const char* const input, char* command,

View File

@@ -147,7 +147,7 @@ void
ui_update(void)
{
ProfWin* current = wins_get_current();
if (current->layout->paged == 0) {
if (!win_is_paged(current)) {
win_move_to_end(current);
}

View File

@@ -46,7 +46,6 @@
#include "ui/ui.h"
#include "ui/titlebar.h"
#include "ui/inputwin.h"
#include "ui/win_types.h"
#include "ui/window_list.h"
#include "ui/window.h"
#include "ui/screen.h"
@@ -253,10 +252,6 @@ _title_bar_draw(void)
_show_muc_privacy(mucwin);
_show_attention(current, mucwin->has_attention);
_show_scrolled(current);
} else if (current && current->type == WIN_AI) {
ProfAiWin* aiwin = (ProfAiWin*)current;
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
_show_scrolled(current);
}
_show_self_presence();
@@ -288,7 +283,7 @@ _show_attention(ProfWin* current, gboolean attention)
static void
_show_scrolled(ProfWin* current)
{
if (current && current->layout->paged == 1) {
if (win_is_paged(current)) {
int bracket_attrs = theme_attrs(THEME_TITLE_BRACKET);
int scrolled_attrs = theme_attrs(THEME_TITLE_SCROLLED);

View File

@@ -120,7 +120,6 @@ typedef struct prof_layout_t
WINDOW* win;
ProfBuff buffer;
int y_pos;
int paged;
int unread_msg;
} ProfLayout;
@@ -149,16 +148,24 @@ typedef enum {
WIN_AI
} win_type_t;
typedef enum {
WIN_SCROLL_INNER,
WIN_SCROLL_REACHED_TOP,
WIN_SCROLL_REACHED_BOTTOM
} win_scroll_state_t;
// ScrollEdges tracks the two end-of-history conditions independently,
// disentangling buffer-layout state (handled implicitly via y_pos) from
// DB-content state. db_exhausted_above means a previous page-up fetch came
// back empty: there is nothing older in the chat database, so the next
// page-up should not re-issue the SQL/flatfile read. db_exhausted_below
// is the analogous flag for newer rows. Both default to FALSE, are flipped
// to TRUE on a respective DB_RESPONSE_EMPTY fetch, and are cleared by
// scrolling in the opposite direction so a re-attempt happens after new
// activity (e.g. MAM late delivery, /history switch).
typedef struct {
gboolean db_exhausted_above;
gboolean db_exhausted_below;
} ScrollEdges;
typedef struct prof_win_t
{
win_type_t type;
win_scroll_state_t scroll_state;
ScrollEdges scroll_edges;
ProfLayout* layout;
Autocomplete urls_ac;
Autocomplete quotes_ac;

View File

@@ -84,6 +84,26 @@ _check_subwin_width(int cols, int width)
return cols <= 1 ? 1 : CLAMP(width, 1, cols - 1);
}
// Visible chat-area height in lines: stdscr minus the title bar (1), the
// status bar (1), the input prompt area (1), and the separator (1).
int
win_page_space(void)
{
return getmaxy(stdscr) - 4;
}
// True when the user has scrolled away from the live tail and there is
// content below the visible viewport. Derived from y_pos and the current
// height of the pad — no sticky flag, no possibility of staleness.
gboolean
win_is_paged(const ProfWin* window)
{
if (!window || !window->layout || !window->layout->win)
return FALSE;
int total_rows = getcury(window->layout->win);
return (total_rows - window->layout->y_pos) > win_page_space();
}
int
win_roster_cols(void)
{
@@ -117,7 +137,6 @@ _win_create_simple_layout(void)
wbkgd(layout->base.win, theme_attrs(THEME_TEXT));
layout->base.buffer = buffer_create();
layout->base.y_pos = 0;
layout->base.paged = 0;
layout->base.unread_msg = 0;
scrollok(layout->base.win, TRUE);
@@ -135,7 +154,6 @@ _win_create_split_layout(void)
wbkgd(layout->base.win, theme_attrs(THEME_TEXT));
layout->base.buffer = buffer_create();
layout->base.y_pos = 0;
layout->base.paged = 0;
layout->base.unread_msg = 0;
scrollok(layout->base.win, TRUE);
layout->subwin = NULL;
@@ -150,7 +168,7 @@ win_create_console(void)
{
ProfConsoleWin* new_win = malloc(sizeof(ProfConsoleWin));
new_win->window.type = WIN_CONSOLE;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_split_layout();
return &new_win->window;
@@ -162,7 +180,7 @@ win_create_chat(const char* const barejid)
assert(barejid != NULL);
ProfChatWin* new_win = malloc(sizeof(ProfChatWin));
new_win->window.type = WIN_CHAT;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->barejid = strdup(barejid);
@@ -196,7 +214,7 @@ win_create_muc(const char* const roomjid)
int cols = getmaxx(stdscr);
new_win->window.type = WIN_MUC;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
ProfLayoutSplit* layout = malloc(sizeof(ProfLayoutSplit));
layout->base.type = LAYOUT_SPLIT;
@@ -215,7 +233,6 @@ win_create_muc(const char* const roomjid)
layout->memcheck = LAYOUT_SPLIT_MEMCHECK;
layout->base.buffer = buffer_create();
layout->base.y_pos = 0;
layout->base.paged = 0;
layout->base.unread_msg = 0;
scrollok(layout->base.win, TRUE);
new_win->window.layout = (ProfLayout*)layout;
@@ -254,7 +271,7 @@ win_create_config(const char* const roomjid, DataForm* form, ProfConfWinCallback
assert(form != NULL);
ProfConfWin* new_win = malloc(sizeof(ProfConfWin));
new_win->window.type = WIN_CONFIG;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->roomjid = strdup(roomjid);
new_win->form = form;
@@ -273,7 +290,7 @@ win_create_private(const char* const fulljid)
assert(fulljid != NULL);
ProfPrivateWin* new_win = malloc(sizeof(ProfPrivateWin));
new_win->window.type = WIN_PRIVATE;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->fulljid = strdup(fulljid);
new_win->unread = 0;
@@ -290,7 +307,7 @@ win_create_xmlconsole(void)
{
ProfXMLWin* new_win = malloc(sizeof(ProfXMLWin));
new_win->window.type = WIN_XML;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->memcheck = PROFXMLWIN_MEMCHECK;
@@ -305,7 +322,7 @@ win_create_plugin(const char* const plugin_name, const char* const tag)
assert(tag != NULL);
ProfPluginWin* new_win = malloc(sizeof(ProfPluginWin));
new_win->window.type = WIN_PLUGIN;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->tag = strdup(tag);
@@ -322,7 +339,7 @@ win_create_vcard(vCard* vcard)
assert(vcard != NULL);
ProfVcardWin* new_win = malloc(sizeof(ProfVcardWin));
new_win->window.type = WIN_VCARD;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->vcard = vcard;
@@ -336,7 +353,7 @@ win_create_ai(AISession* session)
{
ProfAiWin* new_win = g_new0(ProfAiWin, 1);
new_win->window.type = WIN_AI;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->message_buffer = buffer_create();
@@ -722,11 +739,13 @@ win_page_up(ProfWin* window, int scroll_size)
int* page_start = &(window->layout->y_pos);
int page_start_initial = *page_start;
// Size of the visible chat page
int page_space = getmaxy(stdscr) - 4;
int page_space = win_page_space();
if (scroll_size == 0)
scroll_size = page_space;
win_scroll_state_t* scroll_state = &window->scroll_state;
*scroll_state = (*scroll_state == WIN_SCROLL_REACHED_BOTTOM) ? WIN_SCROLL_INNER : *scroll_state;
ScrollEdges* edges = &window->scroll_edges;
// Going up means the bottom of the DB may have new content again; allow
// a re-fetch on the next page-down.
edges->db_exhausted_below = FALSE;
*page_start -= scroll_size;
gboolean has_scroll_past_buffer = *page_start < 0;
@@ -736,16 +755,18 @@ win_page_up(ProfWin* window, int scroll_size)
gboolean is_still_fetching_mam = first_entry && (first_entry->theme_item == THEME_ROOMINFO && g_strcmp0(first_entry->message, LOADING_MESSAGE) == 0);
if (!is_still_fetching_mam) {
// WIN_SCROLL_REACHED_TOP means that we reached top of DB and there is no need to refetch data from it
gboolean is_db_offset_jump_needed = false;
if (*scroll_state != WIN_SCROLL_REACHED_TOP) {
// db_exhausted_above suppresses further upward DB fetches; once
// a previous fetch returned empty, there is nothing older.
gboolean is_db_offset_jump_needed = FALSE;
if (!edges->db_exhausted_above) {
db_history_result_t db_response = chatwin_db_history(chatwin, NULL, NULL, TRUE);
is_db_offset_jump_needed = db_response == DB_RESPONSE_SUCCESS;
*scroll_state = db_response == DB_RESPONSE_EMPTY ? WIN_SCROLL_REACHED_TOP : WIN_SCROLL_INNER;
log_debug("Scroll state after DB history fetch: %s", *scroll_state ? "WIN_SCROLL_REACHED_TOP" : "WIN_SCROLL_INNER");
edges->db_exhausted_above = (db_response == DB_RESPONSE_EMPTY);
log_debug("Scroll edges after DB history fetch: above=%d below=%d",
edges->db_exhausted_above, edges->db_exhausted_below);
}
if (*scroll_state == WIN_SCROLL_REACHED_TOP) {
if (edges->db_exhausted_above) {
*page_start = 0;
if (prefs_get_boolean(PREF_MAM)) {
win_print_loading_history(window);
@@ -777,9 +798,7 @@ win_page_up(ProfWin* window, int scroll_size)
win_update_virtual(window);
}
// Update scrolling state
int total_rows = getcury(window->layout->win);
window->layout->paged = (total_rows) - *page_start > page_space;
// paged is derived on demand from y_pos / total_rows; nothing to set here.
}
void
@@ -788,13 +807,15 @@ win_page_down(ProfWin* window, int scroll_size)
int total_rows = getcury(window->layout->win);
int total_rows_with_unread = total_rows + window->layout->unread_msg;
int* page_start = &(window->layout->y_pos);
int page_space = getmaxy(stdscr) - 4;
int page_space = win_page_space();
int page_start_initial = *page_start;
if (scroll_size == 0)
scroll_size = page_space;
win_scroll_state_t* scroll_state = &window->scroll_state;
*scroll_state = (*scroll_state == WIN_SCROLL_REACHED_TOP) ? WIN_SCROLL_INNER : *scroll_state;
ScrollEdges* edges = &window->scroll_edges;
// Going down means the top of the DB may have new content again; allow
// a re-fetch on the next page-up (e.g. MAM late delivery).
edges->db_exhausted_above = FALSE;
*page_start += scroll_size;
@@ -805,7 +826,7 @@ win_page_down(ProfWin* window, int scroll_size)
if ((past_bottom || at_page_space_and_past_unread) && is_chat) {
int bf_size = buffer_size(window->layout->buffer);
if (bf_size > 0 && *scroll_state != WIN_SCROLL_REACHED_BOTTOM) {
if (bf_size > 0 && !edges->db_exhausted_below) {
// How many lines are left until end of the screen
int current_offset = total_rows - *page_start;
GDateTime* now = g_date_time_new_now_local();
@@ -814,7 +835,7 @@ win_page_down(ProfWin* window, int scroll_size)
auto_gchar gchar* end_date = g_date_time_format_iso8601(now);
db_history_result_t db_response = chatwin_db_history((ProfChatWin*)window, start, end_date, FALSE);
if (db_response == DB_RESPONSE_EMPTY)
*scroll_state = WIN_SCROLL_REACHED_BOTTOM;
edges->db_exhausted_below = TRUE;
// similar to page_up (see explanation there)
// Get offset of the message that was the latest prior to loading messages from the DB
@@ -826,34 +847,22 @@ win_page_down(ProfWin* window, int scroll_size)
total_rows = getcury(window->layout->win);
// near the end, but will display only part of the page space, move a bit back to show full page
if ((total_rows - *page_start) < page_space) {
*page_start = total_rows - page_space;
// went past end, show last page
} else if (*page_start >= total_rows) {
*page_start = total_rows - page_space - 1;
// Clamp to the last full page so the viewport never goes past the buffer
// and always shows a full page when one is available.
int last_page_start = total_rows > page_space ? total_rows - page_space : 0;
if (*page_start > last_page_start) {
*page_start = last_page_start;
}
window->layout->paged = 1;
// update only if position has changed
if ((page_start_initial != *page_start) || window->layout->unread_msg) {
win_update_virtual(window);
}
/* Switch off page if no messages left to read.
* TODO: update buffer end handling to check messages just after last entry.
*/
if (*scroll_state == WIN_SCROLL_REACHED_BOTTOM) {
window->layout->paged = 0;
window->layout->unread_msg = 0;
}
// Non-chat windows have no DB to fetch from, so WIN_SCROLL_REACHED_BOTTOM
// never fires; reset paged once the buffer's last line is on screen.
if (window->type != WIN_CHAT
&& (total_rows - *page_start) <= page_space + 1) {
window->layout->paged = 0;
// Once the user is back at the buffer's last page the unread badge no
// longer reflects anything they have not seen — clear it. paged itself
// is derived from y_pos so there is no flag to flip.
if (*page_start >= last_page_start) {
window->layout->unread_msg = 0;
}
}
@@ -862,8 +871,7 @@ void
win_sub_page_down(ProfWin* window)
{
if (window->layout->type == LAYOUT_SPLIT) {
int rows = getmaxy(stdscr);
int page_space = rows - 4;
int page_space = win_page_space();
ProfLayoutSplit* split_layout = (ProfLayoutSplit*)window->layout;
int sub_y = getcury(split_layout->subwin);
int* sub_y_pos = &(split_layout->sub_y_pos);
@@ -886,8 +894,7 @@ void
win_sub_page_up(ProfWin* window)
{
if (window->layout->type == LAYOUT_SPLIT) {
int rows = getmaxy(stdscr);
int page_space = rows - 4;
int page_space = win_page_space();
ProfLayoutSplit* split_layout = (ProfLayoutSplit*)window->layout;
int* sub_y_pos = &(split_layout->sub_y_pos);
@@ -914,7 +921,6 @@ win_clear(ProfWin* window)
int y = getcury(window->layout->win);
int* page_start = &(window->layout->y_pos);
*page_start = y;
window->layout->paged = 1;
window->layout->unread_msg = 0;
win_update_virtual(window);
}
@@ -1024,14 +1030,12 @@ win_refresh_with_subwin(ProfWin* window)
void
win_move_to_end(ProfWin* window)
{
window->layout->paged = 0;
window->layout->unread_msg = 0;
int rows = getmaxy(stdscr);
int y = getcury(window->layout->win);
int size = rows - 3;
int page_space = win_page_space();
window->layout->y_pos = y - (size - 1);
window->layout->y_pos = y - page_space;
if (window->layout->y_pos < 0) {
window->layout->y_pos = 0;
}
@@ -1808,14 +1812,13 @@ win_newline(ProfWin* window)
static void
_win_printf(ProfWin* window, const char* show_char, int pad_indent, GDateTime* timestamp, int flags, theme_item_t theme_item, const char* const display_from, const char* const from_jid, const char* const message_id, const char* const message, ...)
{
if (window->layout->paged && wins_is_current(window)) {
window->layout->unread_msg++;
// Skip buffer only when DB can replay the message on scroll-down.
if (window->type == WIN_CHAT && log_database_can_recover_messages()) {
return;
}
}
/* Append the message to the curses pad and the in-memory buffer
* unconditionally. When the user is scrolled up (paged), the new line
* lands on rows below the visible viewport so it does not disturb what
* they are reading; they encounter it naturally on page-down. The
* unread badge is bumped so the title bar still reflects new activity.
*/
const gboolean is_paged_now = win_is_paged(window) && wins_is_current(window);
if (timestamp == NULL) {
timestamp = g_date_time_new_now_local();
@@ -1832,6 +1835,10 @@ _win_printf(ProfWin* window, const char* show_char, int pad_indent, GDateTime* t
_win_print_internal(window, show_char, pad_indent, timestamp, flags, theme_item, display_from, msg, NULL);
buffer_append(window->layout->buffer, show_char, pad_indent, timestamp, flags, theme_item, display_from, from_jid, msg, NULL, message_id, y_start_pos, getcury(window->layout->win));
if (is_paged_now) {
window->layout->unread_msg++;
}
inp_nonblocking(TRUE);
g_date_time_unref(timestamp);

View File

@@ -81,6 +81,8 @@ void win_redraw(ProfWin* window);
void win_print_loading_history(ProfWin* window);
int win_roster_cols(void);
int win_occpuants_cols(void);
int win_page_space(void);
gboolean win_is_paged(const ProfWin* window);
void win_sub_print(WINDOW* win, char* msg, gboolean newline, gboolean wrap, int indent);
void win_sub_newline_lazy(WINDOW* win);
void win_mark_received(ProfWin* window, const char* const id);

View File

@@ -867,6 +867,24 @@ wins_get_prune_wins(void)
return result;
}
ProfAiWin*
wins_get_ai(void)
{
GList* curr = values;
while (curr) {
ProfWin* window = curr->data;
if (window->type == WIN_AI) {
ProfAiWin* aiwin = (ProfAiWin*)window;
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
return aiwin;
}
curr = g_list_next(curr);
}
return NULL;
}
gboolean
wins_ai_exists(ProfAiWin* const aiwin)
{

View File

@@ -63,6 +63,7 @@ ProfPrivateWin* wins_get_private(const char* const fulljid);
ProfPluginWin* wins_get_plugin(const char* const tag);
ProfXMLWin* wins_get_xmlconsole(void);
ProfVcardWin* wins_get_vcard(void);
ProfAiWin* wins_get_ai(void);
gboolean wins_ai_exists(ProfAiWin* const aiwin);
void wins_close_plugin(char* tag);

View File

@@ -59,7 +59,6 @@
#ifdef HAVE_SQLITE
#include "test_export_import.h"
#endif
#include "test_ai.h"
/* Macro to wrap each test with setup/teardown functions */
#define PROF_FUNC_TEST(test) \
@@ -67,14 +66,6 @@
#test, test, init_prof_test, close_prof_test, #test \
}
/* AI tests use a custom init that primes stabber via prof_connect so
* stbbr_stop() in close_prof_test() doesn't hang on a never-connected
* server thread. See ai_init_test() in test_ai.c. */
#define PROF_FUNC_TEST_AI(test) \
{ \
#test, test, ai_init_test, close_prof_test, #test \
}
#ifdef HAVE_SQLITE
/* Custom init that sets a non-UTC timezone (POSIX TST-3 = UTC+3) */
static int
@@ -93,9 +84,9 @@ main(int argc, char* argv[])
int group = 0; /* 0 = all groups */
if (argc > 1) {
group = atoi(argv[1]);
if (group < 1 || group > 5) {
if (group < 1 || group > 4) {
fprintf(stderr, "Usage: %s [group]\n", argv[0]);
fprintf(stderr, " group: 1-5 to run specific group, or omit for all\n");
fprintf(stderr, " group: 1-4 to run specific group, or omit for all\n");
return 1;
}
}
@@ -316,31 +307,6 @@ main(int argc, char* argv[])
#endif
};
/* ============================================================
* GROUP 5: AI command surface (console + HTTP stub)
* Standalone because AI tests don't use stabber's XMPP path; mixing
* them with XMPP-bound tests in the same group poisons stabber state
* between fixture init/teardown cycles and hangs subsequent
* prof_connect() calls.
* ============================================================ */
const struct CMUnitTest group5_tests[] = {
/* Console-only — no network calls */
PROF_FUNC_TEST_AI(ai_no_args_shows_help),
PROF_FUNC_TEST_AI(ai_providers_lists_defaults),
PROF_FUNC_TEST_AI(ai_providers_list_shows_key_status),
PROF_FUNC_TEST_AI(ai_set_provider_adds_custom),
PROF_FUNC_TEST_AI(ai_set_token_marks_key_set),
PROF_FUNC_TEST_AI(ai_start_unknown_provider_errors),
PROF_FUNC_TEST_AI(ai_start_without_key_errors),
PROF_FUNC_TEST_AI(ai_start_with_key_opens_window),
PROF_FUNC_TEST_AI(ai_clear_without_window_errors),
PROF_FUNC_TEST_AI(ai_remove_provider_works),
PROF_FUNC_TEST_AI(ai_remove_provider_unknown_errors),
PROF_FUNC_TEST_AI(ai_set_default_model_updates_provider),
PROF_FUNC_TEST_AI(ai_switch_without_window_errors),
PROF_FUNC_TEST_AI(ai_bad_subcommand_shows_usage),
};
/* Test group registry for easy extension */
struct
{
@@ -352,7 +318,6 @@ main(int argc, char* argv[])
{ "Group 2: ExportImport/Receipts", group2_tests, ARRAY_SIZE(group2_tests) },
{ "Group 3: Presence/Disconnect/DBHistory/Message/MUC-DB", group3_tests, ARRAY_SIZE(group3_tests) },
{ "Group 4: Session/MUC/Carbons/MigrationStress", group4_tests, ARRAY_SIZE(group4_tests) },
{ "Group 5: AI command surface (console only)", group5_tests, ARRAY_SIZE(group5_tests) },
};
const int num_groups = ARRAY_SIZE(groups);

View File

@@ -20,7 +20,7 @@
#include "proftest.h"
/* Number of parallel test groups for CI builds */
#define TEST_GROUPS 5
#define TEST_GROUPS 4
/* Number of ports reserved per test group; allows skipping TIME_WAIT ports */
#define PORTS_PER_GROUP 50

View File

@@ -1,252 +0,0 @@
/*
* test_ai.c
*
* Functional tests for the /ai command surface (Tier A — no network).
*
* These tests interact with profanity through its PTY console and exercise
* paths that do not reach libcurl: command parsing, provider registration,
* key/setting/default storage, error messages, and AI window creation.
*
* Tests that require an actual provider HTTP exchange (prompt -> response,
* /ai models <provider>, HTTP error envelopes) are out of scope here and
* belong in a separate suite backed by a local HTTP stub.
*/
#include <glib.h>
#include "prof_cmocka.h"
#include <stdlib.h>
#include <string.h>
#include "proftest.h"
#include "test_ai.h"
int
ai_init_test(void** state)
{
int ret = init_prof_test(state);
if (ret != 0) {
return ret;
}
/* Connect to stabber even though AI tests don't use XMPP. Without this
* stabber's accept loop has no client to disconnect on /quit, and
* stbbr_stop() in close_prof_test() hangs uninterruptibly when joining
* the server thread. */
prof_connect();
return 0;
}
void
ai_no_args_shows_help(void** state)
{
/* `/ai` with no arguments lists the built-in providers and a usage hint.
* We don't pin specific provider names — defaults may change. Verify the
* header, the "Configured providers" section, that *some* provider line
* carries an http(s) URL, and the usage hint. */
prof_input("/ai");
prof_timeout(5);
assert_true(prof_output_exact("AI Chat - OpenAI-compatible API client"));
assert_true(prof_output_exact("Configured providers:"));
assert_true(prof_output_regex("URL: https?://"));
assert_true(prof_output_regex("Use '/ai start' to begin a chat"));
prof_timeout_reset();
}
void
ai_providers_lists_defaults(void** state)
{
/* `/ai providers` (no "list") shows the built-in list with URLs. */
prof_input("/ai providers");
prof_timeout(5);
assert_true(prof_output_exact("Available AI providers:"));
/* At least one URL line is rendered — exact name agnostic. */
assert_true(prof_output_regex("https?://"));
prof_timeout_reset();
}
void
ai_providers_list_shows_key_status(void** state)
{
/* `/ai providers list` lists each configured provider with key status. */
prof_input("/ai providers list");
prof_timeout(5);
assert_true(prof_output_exact("Configured providers:"));
/* No tokens have been set yet in this fresh session. */
assert_true(prof_output_regex("Key: NOT configured"));
prof_timeout_reset();
}
void
ai_set_provider_adds_custom(void** state)
{
/* Adding a custom provider should make it appear in /ai providers list. */
prof_input("/ai set provider mock http://127.0.0.1:1/");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'mock' configured"));
prof_timeout_reset();
prof_input("/ai providers list");
prof_timeout(5);
assert_true(prof_output_regex("mock"));
assert_true(prof_output_regex("http://127.0.0.1:1/"));
prof_timeout_reset();
}
void
ai_set_token_marks_key_set(void** state)
{
/* Use a self-set-up provider so the test doesn't pin a default name. */
prof_input("/ai set provider testprov https://example.test/");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'testprov' configured"));
prof_timeout_reset();
prof_input("/ai set token testprov sk-fake-test-key");
prof_timeout(5);
assert_true(prof_output_regex("API token set for provider: testprov"));
prof_timeout_reset();
prof_input("/ai providers list");
prof_timeout(5);
assert_true(prof_output_regex("Key: configured"));
prof_timeout_reset();
}
void
ai_start_unknown_provider_errors(void** state)
{
/* Unknown provider name should error out without creating a window. */
prof_input("/ai start nope_provider somemodel");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'nope_provider' not found"));
prof_timeout_reset();
}
void
ai_start_without_key_errors(void** state)
{
/* Self-set-up provider with no token. /ai start must refuse. */
prof_input("/ai set provider testprov https://example.test/");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'testprov' configured"));
prof_timeout_reset();
prof_input("/ai start testprov model-x");
prof_timeout(5);
assert_true(prof_output_regex("No API key set for provider 'testprov'"));
prof_timeout_reset();
}
void
ai_start_with_key_opens_window(void** state)
{
/* Self-set-up provider with token; /ai start opens a WIN_AI window. */
prof_input("/ai set provider testprov https://example.test/");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'testprov' configured"));
prof_timeout_reset();
prof_input("/ai set token testprov sk-fake-test-key");
prof_timeout(5);
assert_true(prof_output_regex("API token set for provider: testprov"));
prof_timeout_reset();
prof_input("/ai start testprov model-x");
prof_timeout(5);
/* /ai start switches focus to the new WIN_AI window; the window prints
* "AI Chat: <provider>/<model>" as its first line. */
assert_true(prof_output_regex("AI Chat: testprov/model-x"));
prof_timeout_reset();
}
void
ai_clear_without_window_errors(void** state)
{
/* /ai clear from the console (no active AI window) refuses with the
* shared "must be in AI chat window" guard used by /ai switch as well. */
prof_input("/ai clear");
prof_timeout(5);
assert_true(prof_output_regex("Must be in an AI chat window"));
prof_timeout_reset();
}
void
ai_remove_provider_works(void** state)
{
/* Round-trip: add -> verify present -> remove -> verify gone. */
prof_input("/ai set provider tmpprov http://127.0.0.1:2/");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'tmpprov' configured"));
prof_timeout_reset();
prof_input("/ai remove provider tmpprov");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'tmpprov' removed"));
prof_timeout_reset();
prof_input("/ai remove provider tmpprov");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'tmpprov' not found"));
prof_timeout_reset();
}
void
ai_remove_provider_unknown_errors(void** state)
{
/* Removing a provider that was never added must report "not found". */
prof_input("/ai remove provider nonexistent_xyz");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'nonexistent_xyz' not found"));
prof_timeout_reset();
}
void
ai_set_default_model_updates_provider(void** state)
{
/* Setting a default model is acknowledged on the console. */
prof_input("/ai set provider testprov https://example.test/");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'testprov' configured"));
prof_timeout_reset();
prof_input("/ai set default-model testprov model-preview");
prof_timeout(5);
assert_true(prof_output_regex("Default model for provider 'testprov' set to: model-preview"));
prof_timeout_reset();
}
void
ai_switch_without_window_errors(void** state)
{
/* /ai switch with no active AI window refuses with the shared
* "must be in AI chat window" guard. */
prof_input("/ai switch testprov model-x");
prof_timeout(5);
assert_true(prof_output_regex("Must be in an AI chat window"));
prof_timeout_reset();
}
void
ai_bad_subcommand_shows_usage(void** state)
{
/* An unknown /ai subcommand should fall through to cons_bad_cmd_usage. */
prof_input("/ai not_a_subcommand");
prof_timeout(5);
assert_true(prof_output_regex("Invalid usage, see '/help ai'"));
prof_timeout_reset();
}

View File

@@ -1,27 +0,0 @@
#ifndef __H_FUNC_TEST_AI
#define __H_FUNC_TEST_AI
/*
* AI tests don't exercise the XMPP path, but the test fixture's
* stbbr_stop() hangs indefinitely when no client ever connected to
* stabber. ai_init_test wraps init_prof_test with a prof_connect()
* so stabber sees a graceful disconnect at teardown.
*/
int ai_init_test(void** state);
void ai_no_args_shows_help(void** state);
void ai_providers_lists_defaults(void** state);
void ai_providers_list_shows_key_status(void** state);
void ai_set_provider_adds_custom(void** state);
void ai_set_token_marks_key_set(void** state);
void ai_start_unknown_provider_errors(void** state);
void ai_start_without_key_errors(void** state);
void ai_start_with_key_opens_window(void** state);
void ai_clear_without_window_errors(void** state);
void ai_remove_provider_works(void** state);
void ai_remove_provider_unknown_errors(void** state);
void ai_set_default_model_updates_provider(void** state);
void ai_switch_without_window_errors(void** state);
void ai_bad_subcommand_shows_usage(void** state);
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -31,13 +31,14 @@ void test_ai_session_create_null_model_returns_null(void** state);
void test_ai_session_api_key_null_when_no_key_set(void** state);
/* Provider autocomplete tests */
void test_ai_providers_find_forward(void** state);
void test_ai_providers_find_forward_perplexity(void** state);
void test_ai_providers_find_forward_custom(void** state);
void test_ai_providers_find_forward_no_match(void** state);
void test_ai_providers_find_forward_partial_match(void** state);
void test_ai_providers_find_next(void** state);
void test_ai_providers_find_previous(void** state);
void test_ai_providers_find_empty_search_str(void** state);
void test_ai_providers_find_null_search_str(void** state);
void test_ai_providers_find_empty_search_str(void** state);
void test_ai_providers_find_case_insensitive(void** state);
/* Provider default model and settings tests */
void test_ai_set_provider_default_model(void** state);
@@ -56,85 +57,6 @@ void test_ai_parse_models_escaped_quotes(void** state);
void test_ai_parse_models_with_whitespace(void** state);
/* AI autocomplete integration tests */
void test_ai_start_provider_autocomplete_only_on_exact(void** state);
void test_ai_models_find_null_session(void** state);
void test_ai_models_find_null_provider(void** state);
void test_ai_providers_find_cycling(void** state);
/* Setup that also initializes prefs for round-trip persistence tests. */
int ai_client_setup_with_prefs(void** state);
int ai_client_teardown_with_prefs(void** state);
/* Chat response parser tests (ai_parse_response) */
void test_ai_parse_response_openai_content(void** state);
void test_ai_parse_response_perplexity_text(void** state);
void test_ai_parse_response_text_preferred_over_content(void** state);
void test_ai_parse_response_escaped_quote(void** state);
void test_ai_parse_response_newline_escape(void** state);
void test_ai_parse_response_empty_content(void** state);
void test_ai_parse_response_missing_field(void** state);
void test_ai_parse_response_null_input(void** state);
void test_ai_parse_response_empty_input(void** state);
void test_ai_parse_response_percent_signs_safe(void** state);
void test_ai_parse_response_braces_in_content(void** state);
void test_ai_parse_response_multiline_content(void** state);
/* Error response parser tests (ai_parse_error_message) */
void test_ai_parse_error_standard_envelope(void** state);
void test_ai_parse_error_with_escapes(void** state);
void test_ai_parse_error_no_error_field(void** state);
void test_ai_parse_error_no_message_field(void** state);
void test_ai_parse_error_null_input(void** state);
void test_ai_parse_error_empty_input(void** state);
void test_ai_parse_error_tab_escape(void** state);
void test_ai_parse_error_backslash_escape(void** state);
/* Extended JSON escape tests */
void test_ai_json_escape_backspace(void** state);
void test_ai_json_escape_formfeed(void** state);
void test_ai_json_escape_carriage_return(void** state);
void test_ai_json_escape_all_specials_combined(void** state);
void test_ai_json_escape_passes_utf8_through(void** state);
/* Autocomplete cycling with many providers */
void test_ai_providers_find_cycles_through_many(void** state);
void test_ai_providers_find_previous_walks_backwards(void** state);
void test_ai_providers_find_wraps_around(void** state);
/* Session edge cases */
void test_ai_session_add_message_null_session_no_crash(void** state);
void test_ai_session_add_message_null_role_no_crash(void** state);
void test_ai_session_add_message_null_content_no_crash(void** state);
void test_ai_session_history_preserves_large_order(void** state);
void test_ai_session_clear_empty_history(void** state);
void test_ai_session_set_model_null_keeps_old(void** state);
void test_ai_session_unref_null_no_crash(void** state);
void test_ai_session_ref_null_returns_null(void** state);
/* Provider edge cases */
void test_ai_get_provider_null_returns_null(void** state);
void test_ai_remove_provider_null_returns_false(void** state);
void test_ai_remove_provider_twice_second_fails(void** state);
void test_ai_provider_survives_via_session_after_removal(void** state);
/* Settings edge cases */
void test_ai_settings_multiple_keys_independent(void** state);
void test_ai_settings_get_missing_returns_null(void** state);
void test_ai_settings_isolated_between_providers(void** state);
/* Model parsing edge cases */
void test_ai_parse_models_data_not_array(void** state);
void test_ai_parse_models_empty_data_array(void** state);
void test_ai_parse_models_id_outside_data_ignored(void** state);
void test_ai_parse_models_multiple_models(void** state);
/* Prefs round-trip tests */
void test_ai_prefs_round_trip_api_key(void** state);
void test_ai_prefs_round_trip_remove_key(void** state);
void test_ai_prefs_multiple_providers_persist(void** state);
/* Autocomplete deeper coverage */
void test_ai_providers_find_after_remove_skips_removed(void** state);
/* Reset hook + persistence */
void test_ai_providers_reset_ac_restarts_cycle(void** state);
void test_ai_add_provider_persisted_across_init(void** state);
void test_ai_remove_provider_persisted_across_init(void** state);
void test_ai_models_persisted_across_init(void** state);

View File

@@ -1433,3 +1433,8 @@ cons_has_alerts(void)
{
return FALSE;
}
void
win_move_to_end(ProfWin* window)
{
}

View File

@@ -681,16 +681,14 @@ main(int argc, char* argv[])
cmocka_unit_test_setup_teardown(test_ai_session_api_key_null_when_no_key_set, ai_client_setup, ai_client_teardown),
/* Provider autocomplete tests */
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_perplexity, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_custom, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_no_match, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_partial_match, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_next, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_previous, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_null_search_str, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_empty_search_str, ai_client_setup, ai_client_teardown),
/* SIGSEGV on ai_providers_find(NULL, ...) — see test body and
* AI_AUTOCOMPLETE_POSSIBLE_ISSUES.md, issue 1. Re-enable once the
* NULL-search path is guarded. */
/* cmocka_unit_test_setup_teardown(test_ai_providers_find_null_search_str, ai_client_setup, ai_client_teardown), */
cmocka_unit_test_setup_teardown(test_ai_providers_find_case_insensitive, ai_client_setup, ai_client_teardown),
/* Provider default model and settings tests */
cmocka_unit_test_setup_teardown(test_ai_set_provider_default_model, ai_client_setup, ai_client_teardown),
@@ -709,6 +707,8 @@ main(int argc, char* argv[])
cmocka_unit_test(test_ai_parse_models_with_whitespace),
/* AI autocomplete integration tests */
cmocka_unit_test_setup_teardown(test_ai_start_provider_autocomplete_only_on_exact, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_models_find_null_session, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_models_find_null_provider, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_cycling, ai_client_setup, ai_client_teardown),
cmocka_unit_test(test_ai_json_escape),
cmocka_unit_test(test_ai_json_escape_null),
@@ -716,72 +716,6 @@ main(int argc, char* argv[])
cmocka_unit_test(test_ai_json_escape_special_chars),
cmocka_unit_test(test_ai_json_escape_percent_signs),
cmocka_unit_test(test_ai_json_escape_backslash_quote),
/* Chat response parser */
cmocka_unit_test(test_ai_parse_response_openai_content),
cmocka_unit_test(test_ai_parse_response_perplexity_text),
cmocka_unit_test(test_ai_parse_response_text_preferred_over_content),
cmocka_unit_test(test_ai_parse_response_escaped_quote),
cmocka_unit_test(test_ai_parse_response_newline_escape),
cmocka_unit_test(test_ai_parse_response_empty_content),
cmocka_unit_test(test_ai_parse_response_missing_field),
cmocka_unit_test(test_ai_parse_response_null_input),
cmocka_unit_test(test_ai_parse_response_empty_input),
cmocka_unit_test(test_ai_parse_response_percent_signs_safe),
cmocka_unit_test(test_ai_parse_response_braces_in_content),
cmocka_unit_test(test_ai_parse_response_multiline_content),
/* Error response parser */
cmocka_unit_test(test_ai_parse_error_standard_envelope),
cmocka_unit_test(test_ai_parse_error_with_escapes),
cmocka_unit_test(test_ai_parse_error_no_error_field),
cmocka_unit_test(test_ai_parse_error_no_message_field),
cmocka_unit_test(test_ai_parse_error_null_input),
cmocka_unit_test(test_ai_parse_error_empty_input),
cmocka_unit_test(test_ai_parse_error_tab_escape),
cmocka_unit_test(test_ai_parse_error_backslash_escape),
/* Extended JSON escape coverage */
cmocka_unit_test(test_ai_json_escape_backspace),
cmocka_unit_test(test_ai_json_escape_formfeed),
cmocka_unit_test(test_ai_json_escape_carriage_return),
cmocka_unit_test(test_ai_json_escape_all_specials_combined),
cmocka_unit_test(test_ai_json_escape_passes_utf8_through),
/* Autocomplete cycling */
cmocka_unit_test_setup_teardown(test_ai_providers_find_cycles_through_many, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_previous_walks_backwards, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_wraps_around, ai_client_setup, ai_client_teardown),
/* Session edge cases */
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_session_no_crash, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_role_no_crash, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_content_no_crash, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_history_preserves_large_order, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_clear_empty_history, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_set_model_null_keeps_old, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_unref_null_no_crash, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_ref_null_returns_null, ai_client_setup, ai_client_teardown),
/* Provider edge cases */
cmocka_unit_test_setup_teardown(test_ai_get_provider_null_returns_null, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_remove_provider_null_returns_false, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_remove_provider_twice_second_fails, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_provider_survives_via_session_after_removal, ai_client_setup, ai_client_teardown),
/* Settings */
cmocka_unit_test_setup_teardown(test_ai_settings_multiple_keys_independent, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_settings_get_missing_returns_null, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_settings_isolated_between_providers, ai_client_setup, ai_client_teardown),
/* Model parsing edge cases */
cmocka_unit_test_setup_teardown(test_ai_parse_models_data_not_array, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_parse_models_empty_data_array, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_parse_models_id_outside_data_ignored, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_parse_models_multiple_models, ai_client_setup, ai_client_teardown),
/* Prefs round-trip (uses prefs+ai setup) */
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_remove_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
cmocka_unit_test_setup_teardown(test_ai_prefs_multiple_providers_persist, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
/* Autocomplete deeper coverage */
cmocka_unit_test_setup_teardown(test_ai_providers_find_after_remove_skips_removed, ai_client_setup, ai_client_teardown),
/* Reset hook + persistence */
cmocka_unit_test_setup_teardown(test_ai_providers_reset_ac_restarts_cycle, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_add_provider_persisted_across_init, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
cmocka_unit_test_setup_teardown(test_ai_remove_provider_persisted_across_init, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
cmocka_unit_test_setup_teardown(test_ai_models_persisted_across_init, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
// Flatfile export/import round-trip
cmocka_unit_test(test_ff_roundtrip_simple_chat),
cmocka_unit_test(test_ff_roundtrip_with_all_metadata),

View File

@@ -104,12 +104,6 @@ connection_create_uuid(void)
return NULL;
}
char*
connection_create_stanza_id(void)
{
return NULL;
}
void
connection_free_uuid(char* uuid)
{