Archive of completed development sessions. This file is not loaded by Claude Code by default — it exists as a reference for when a current session needs historical context. Active work is tracked in
PLAN.md.
| Phase | What it delivered | Sessions |
|---|---|---|
| Phase 0 | Ollama client, ContextBuffer, Debouncer foundations | 1–3 |
| Phase A | IBus prototype validated on KDE Wayland (Python) | 4–8 |
| Phase B | Fcitx5 production engine (C++17) with browser compat | 9–14 |
| Phase D | Personalization: corpus collector, style profile, dynamic prompt | 15–17 |
| Phase U | Qt6/QML settings app — full UI prototype | U1a–U1c |
| Phase E | Engine-UI gap closure — config loader, all controls wired end-to-end | E0–E6 |
Files: ibus-engine/engine/inference.py, scripts/benchmark_ollama.py
Objectives:
- Async Ollama HTTP client with streaming support
- Cancel-on-new-request (cancel token pattern)
- TTFT (time-to-first-token) measurement
- Benchmark script: 20 runs against qwen2.5:0.5b, print p50/p95/p99
- Verify <200ms TTFT target on local machine
Commit: feat(inference): async ollama client + TTFT benchmark — p50/p95 results
Files: ibus-engine/engine/context_buffer.py, ibus-engine/tests/test_context_buffer.py
Objectives:
ContextBufferclass withcollections.deque, max 500 chars- Methods:
append_char(),backspace(),set_suggestion(),accept_next_word(),accept_all(),dismiss() context_textproperty returns full buffer as stringaccept_next_word()returns next word + trailing space, advances word index, appends to bufferappend_char()andbackspace()auto-dismiss any active suggestion- Full pytest suite: all state transitions, edge cases (empty buffer, single word, trailing spaces)
Commit: feat(context-buffer): state machine with full pytest suite
Files: ibus-engine/engine/debouncer.py, ibus-engine/tests/test_debouncer.py
Objectives:
Debouncerclass: timer-based, configurable delay (default 180ms)- Each new call cancels the pending timer and restarts
- Thread-safe (used from GLib main thread, fires callback on same thread via GLib.idle_add)
- Tests: rapid-fire calls only trigger once, delay respected, cancel works
Commit: feat(debouncer): 180ms debounce with cancellation + tests
Files: ibus-engine/cotypist.xml, ibus-engine/engine/main.py, ibus-engine/engine/engine.py
Objectives:
cotypist.xml: IBus component descriptor (name, exec path, description)main.py:IBus.init(),IBus.Factory,IBus.main()loopengine.py:CotypistEngine(IBus.EngineBase)skeleton — overridedo_process_key_event,do_focus_in,do_focus_out,do_reset- Engine registers with factory, loads config, initialises ContextBuffer + Debouncer
- Verify:
ibus-daemon --replacepicks up the engine, no crashes on focus
Commit: feat(ibus): engine skeleton + XML registration
Files: ibus-engine/engine/engine.py (extend)
Objectives:
- Implement full key event flow from spec:
- Printable char → pass through, append to buffer, debounce → inference
- Tab (suggestion active) → commit next word, update preedit with remainder
- Shift+Tab → commit entire suggestion, clear preedit
- Escape → dismiss, clear preedit
- Backspace (suggestion active) → dismiss only; (no suggestion) → remove last char
- Enter → dismiss, pass through
- Modifier combos → always pass through
- Key releases → always ignore
- Preedit styling:
IBus.AttrType.UNDERLINE+IBus.AttrType.FOREGROUND0x888888 - Wire debouncer callback to inference, inference result calls
set_suggestion()+ updates preedit
Commit: feat(ibus): preedit ghost text + full key handler UX
Files: ibus-engine/engine/engine.py (extend)
Objectives:
do_focus_out: MUST callcommit_text()on any active preedit before clearing — text is lost otherwisedo_focus_in: clear preedit, reset context bufferdo_reset: same as focus_in- Cancel any in-flight inference on focus change
- Manual test checklist: switch apps mid-suggestion, click away, Ctrl+Tab
Commit: fix(ibus): focus-out commits preedit, focus-in resets state
Files: ibus-engine/engine/config.py, ibus-engine/tests/test_config.py
Objectives:
- TOML config loader using
tomllib(stdlib, Python 3.11+) - Reads from
~/.config/cotypist-linux/config.toml, falls back to defaults - Sections:
[inference](model, ollama_host, debounce_ms, min_context_chars, context_window),[behaviour](tab_accepts_word, passthrough_terminals, blocklist_apps) passthrough_terminals = true+blocklist_apps→ engine returns False immediately for those window classes- Ollama health check on startup:
GET /api/tags, log warning if unreachable - Write default config file if missing
Commit: feat(config): TOML config + app blocklist + ollama health check
Files: docs/testing-ibus.md, ~/.config/environment.d/ibus.conf, ibus-engine/engine/main.py
Objectives:
- Write
~/.config/environment.d/ibus.confwithQT_IM_MODULE=ibus,GTK_IM_MODULE=ibus,XMODIFIERS=@im=ibusso all apps pick up IBus automatically on login (no per-launch env prefixing needed) - Add rotating file logger to
main.py→~/.local/share/xtype/engine.log(always DEBUG, 1 MB / 3 rotations) for post-test analysis - Test in Kate (Qt6, text-input-v2): ghost text appears inline, Tab accepts word-by-word
- Test in Zen Browser (GTK3, text-input-v3):
GTK_IM_MODULE=ibus, verify preedit - Verify Alacritty is blocklisted (no preedit, no glitches)
- Verify focus-out commit in Kate and Zen Browser
- Document any issues in
docs/testing-ibus.md
Outcome: End-to-end validated on KDE Wayland. Known limitations documented in docs/testing-ibus.md — notably that do_focus_in_id is never called by the KWin IBus bridge (app_id always empty), making blocklist-by-app-id inactive on Wayland. Alacritty passthrough works incidentally because terminals don't route printable chars through the IM.
Commit: test(ibus): end-to-end validation on KDE Wayland — Kate + Zen + Alacritty
Files: fcitx5-engine/CMakeLists.txt, fcitx5-engine/data/xtype-addon.conf.in, fcitx5-engine/data/xtype.conf
Objectives:
- CMakeLists.txt: find Fcitx5, C++17, shared library target
xtype-fcitx5 xtype-addon.conf.in: addon type=SharedLibrary, category=InputMethodxtype.conf: IM registration (name, native name, icon)- Verify:
cmake -B build -G Ninja && ninja -C buildproduces the .so
Commit: feat(fcitx5): cmake scaffold + addon descriptor
Files: fcitx5-engine/src/inference_client.h, fcitx5-engine/src/inference_client.cpp
Objectives:
InferenceClientclass: libcurl streaming HTTP POST to Ollama/api/generatestd::threadbackground worker,std::atomic<bool>cancel token- Streaming: parse NDJSON response, accumulate tokens, call
onTokencallback - Marshal result to Fcitx5 main thread via
eventDispatcher().schedule() - Same options as Python: num_predict=30, temperature=0.3, top_p=0.9, stop tokens
Commit: feat(fcitx5): libcurl inference client with streaming + cancel
Files: fcitx5-engine/src/context_buffer.h, fcitx5-engine/src/context_buffer.cpp
Objectives:
- 1:1 port of Python
ContextBufferusingstd::deque<char> - Identical API:
appendChar(),backspace(),setSuggestion(),acceptNextWord(),acceptAll(),dismiss(),contextText() - Unit tests using Catch2 or doctest (add to CMake)
Commit: feat(fcitx5): context buffer C++ port + unit tests
Files: fcitx5-engine/src/xtype.h, fcitx5-engine/src/xtype.cpp
Objectives:
XTypeEngine : public fcitx::InputMethodEngineV2- Override
keyEvent(): same logic as IBus Python engine ic->inputPanel().setClientPreedit()withTextFormatFlag::Underline- Focus-out: dismiss preedit (clear without committing) — ghost text is AI suggestion, not user input; committing on focus-out inserts unwanted text
- Wire
InferenceClientcallbacks to preedit updates on main thread
Commit: feat(fcitx5): full engine core — key handler + preedit + focus
Files: docs/testing-fcitx5.md
Objectives:
- Activate: Settings → Keyboard → Virtual Keyboard → Fcitx5 (must be KWin-launched)
- Disable IBus autostart to avoid conflicts
- Test matrix: Kate ✅, Firefox (GTK_IM_MODULE=fcitx) ✅, Chromium (--wayland-text-input-version=3)
⚠️ , Konsole 🚫, Electron (ELECTRON_OZONE_PLATFORM_HINT=wayland)⚠️ , LibreOffice ✅ - Document results in
docs/testing-fcitx5.md
Commit: test(fcitx5): KDE Plasma Wayland integration matrix
Files: fcitx5-engine/src/xtype.cpp, fcitx5-engine/src/config.h
Root-cause analysis (from debug.log):
Zen Browser — no ghost text:
Log lines like key 'g' prog=zen ctx_len=0 appeared on every keystroke, with ctx_len never growing. Between keystrokes, rapid deactivate prog=zen / activate prog=zen pairs appeared. activate() called resetState() which called _ctx.reset(), wiping the context buffer on every key. Zen (GTK4/Firefox Wayland IM) cycles deactivate+activate per keystroke — this is normal GTK4 behaviour, not a bug. With the original code, min_context_chars=10 was never reachable.
Chromium — ghost text commits on defocus:
Log entries deactivate prog=chromium hasSuggestion=1 confirmed the commitString("") guard saw the suggestion — unlike Kate, where the suggestion was already gone by deactivate(). Yet ghost text still committed. TextFormatFlag::DontCommit is a Fcitx5-internal signal; Chromium's own text-input-v3 implementation ignores it and commits the preedit via the Wayland protocol before the engine's deactivate callback can act.
Resolution:
-
Zen context buffer reset: changed
activate()to NOT callresetState(). NewresetInferenceOnly()helper cancels in-flight inference and increments_genbut leaves_ctxintact. Only wipes_ctxindeactivate()when the program changes (tracked via_lastProgmember). After fix,ctx_lengrows correctly in Zen between keystrokes. -
Chromium focus-out commit: call
clearPreedit(ic)at the TOP ofdeactivate(), unconditionally. This sends an empty preedit to the Wayland compositor before Fcitx5 processes the deactivation commit. Even if Chromium then commits the preedit, it commits an empty string.commitString("")retained as belt-and-suspenders for other IM clients. -
Blocklist case mismatch:
config.hhad{"konsole", "alacritty"}but Fcitx5 reportsic->program()as"Konsole"and"Alacritty"(capitalised). SwitchedisBlocked()to lowercase both sides before matching.
Outcome: Ghost text works correctly in Kate, Zen Browser, and Chromium. Konsole and Alacritty are correctly suppressed by the case-insensitive blocklist.
Commit: fix(fcitx5): browser compat — Zen context reset + Chromium focus-out commit
Files: fcitx5-engine/src/inference_client.h, fcitx5-engine/src/inference_client.cpp, fcitx5-engine/src/xtype.cpp
Objectives:
- Switch default model from
qwen2.5:0.5btoqwen2.5:1.5bfor materially stronger suggestions - Replace single-turn
/api/generatewith chat-format/api/chatusing assistant-prefill: the beginning of the user's current context is injected as the assistant's partial reply, so the model continues it rather than echoing from token 0 - Stop token strategy tightened to prevent runaway multi-sentence generation
Outcome: Ghost text quality improved significantly. The assistant-prefill pattern eliminates the echo/repetition loop that appeared with the larger model on plain generate prompts. Sessions 15–17 build on this prompt structure.
Commit: feat(fcitx5): qwen2.5:1.5b + assistant-prefill chat format — anti-loop inference redesign
Files: fcitx5-engine/src/corpus_collector.h, fcitx5-engine/src/corpus_collector.cpp, fcitx5-engine/src/config.h (extend)
Objectives:
CorpusCollectorruns a background thread;record()is non-blocking — pushes text to a bounded deque (cap 1000), flushed every 60 s or when queue exceeds 100 entries- Sentence-level filters at record time: drops code-shaped patterns (
[{};=]{2,}), all-digit/punctuation runs, fragments undermin_sentence_chars - Hard-coded blocklist in
corpus_collector.cpppermanently excludes password managers regardless of user config:keepassxc,1password,bitwarden,gnome-keyring,seahorse - Corpus size cap: atomic rename rotation (
corpus.txt→corpus.txt.1) when file exceedsmax_corpus_mb - Only characters from
appendCharfeed the corpus —acceptNextWord/acceptAlloutput is explicitly excluded so AI suggestions never enter training data - Opt-in by default (
learning.enabled = false)
Commit: feat(personalization): writing corpus collector with opt-in logging + privacy guards
Files: fcitx5-engine/src/style_profile.h, fcitx5-engine/src/style_profile.cpp, fcitx5-engine/tests/test_style_profile.cpp
Objectives:
StyleProfile::loadFromCorpus()streams the corpus line-by-line (no full-file slurp); selects 3–5 length-varied exemplar sentences using a seeded RNG for test determinism- Privacy filters applied before exemplar selection: rejects email-like patterns, sequences of ≥5 digits, sentences containing
password/secret/token/auth(case-insensitive) serialize()/deserialize()JSON round-trip persisted at~/.local/share/xtype/style_profile.json- Staleness check: profile regenerated only when corpus mtime >
last_updated + 30 minor 200+ new lines added — avoids CPU churn during heavy typing - Full Catch2 test suite covering sampling, privacy filters, serialize/deserialize round-trip, and empty-corpus safety
Commit: feat(personalization): style profile extraction with privacy filtering + tests
Files: fcitx5-engine/src/prompt_builder.h, fcitx5-engine/src/prompt_builder.cpp, fcitx5-engine/src/inference_client.h/.cpp, fcitx5-engine/src/xtype.h/.cpp
Objectives:
buildSystemPrompt()assembles in layers: base instruction → user description (About the user) → style exemplars → avoid phrases- Token budget enforced: assembled prompt > 2000 chars triggers drop-longest-exemplar-first until within budget; truncation is logged as a warning
- Profile refresh timer fires every 5 minutes via
eventDispatcher().schedule()on the main thread — background regeneration, main-thread swap InferenceClient::set_system_prompt()mutex-guarded for safe updates between in-flight requests- Manual test matrix verified end-to-end on KDE Wayland and documented
Commit: feat(personalization): dynamic system prompt with style examples + profile refresh
Commit: feat(personalization): observability + manual test matrix for live verification
Files: fcitx5-engine/src/engine_metrics.h, fcitx5-engine/src/recent_events.h, settings/qt6-app/CMakeLists.txt, settings/qt6-app/src/main.cpp, settings/qt6-app/qml/App.qml, settings/qt6-app/qml/Theme.qml, settings/qt6-app/qml/primitives/XSidebar.qml, settings/qt6-app/qml/primitives/XTitleBar.qml
Objectives:
- Engine scaffolded with
EngineMetricsandRecentEventsstructs that the settings app will read from JSON (written by the engine in Phase E) - Qt6/QML project set up: CMakeLists,
main.cpp, Inter + JetBrains Mono variable fonts bundled ThemeQML singleton: color palette, typography scale, grain texture provider for background- Window chrome: frameless
XTitleBar(draggable),XSidebar(navigation + live metrics panel),App.qmlcontent stack
Commit: feat(engine): scaffold config + metrics + recent-events for settings UI
Commit: feat(settings): qt6 project scaffold + main + cmake
Commit: feat(settings): theme singleton + grain provider + bundled fonts
Commit: feat(settings): window chrome + sidebar + titlebar
Files: settings/qt6-app/qml/primitives/ (full library), settings/qt6-app/src/config_store.h/.cpp, settings/qt6-app/src/engine_probe.h/.cpp, settings/qt6-app/src/reloader.h/.cpp, settings/qt6-app/tests/test_config_store.cpp
Objectives:
- Complete QML primitive library:
XToggle,XSlider,XPill,XSegmented,XRow,XCard,XSection,XBars,XButton,XInput,XKbd,XLogList,XPhraseRow,XMiniBar,XAppRow,XEngineDot ConfigStoreC++ backend: allXTypeConfigfields exposed as QML properties; TOML round-trip via toml++; atomic save (write.tmp→ rename); 8 unit tests all greenEngineProbepollsmetrics.jsonandrecent_events.jsonon a timer and emits change signals to the sidebarReloaderissuesfcitx5-remote -rand surfaces the result to a reload banner in the UI- Fixed
ReloadCenterroot type and fcitx5 state detection logic
Commit: feat(settings): primitive library (toggle, slider, pill, segmented, row, bars, …)
Commit: feat(settings): config store with toml round-trip + atomic save + tests
Commit: feat(settings): engine probe + reloader + live sidebar metrics
Files: settings/qt6-app/qml/pages/ (all six pages), settings/qt6-app/data/xtype-settings.desktop, UI layout fixes throughout
Objectives:
- Six pages implemented from the HTML/JSX design reference: PageGeneral, PageBlockList, PagePerApp, PageModel, PagePersonalisation, PageAbout
- KDE
.desktopentry registersxtype-settingsin application menus - Several layout issues resolved after initial port:
Flickablecontent area had zero width (addedanchors.fill),pixelSizerequired an explicit int cast,Gradient.Horizontalnot valid in Qt6 QML
Outcome: xtype-settings binary runs standalone and displays all pages. The engine ↔ UI filesystem bridge is functional: settings written by the app are read by the engine after fcitx5-remote -r.
Commit: feat(settings): pages — general, blocklist, per-app, model, personalisation, about
Commit: feat(settings): qt6/qml standalone settings app — full prototype port
Root cause:
XTypeEnginewas default-constructingXTypeConfigand never reading~/.config/xtype/config.toml. The settings app wrote TOML correctly; the engine ignored it. Sessions E0–E6 close every gap between what the UI exposes and what the engine applies.
Files: fcitx5-engine/src/config_loader.h, fcitx5-engine/src/config_loader.cpp, fcitx5-engine/src/toml/toml.hpp (vendored), fcitx5-engine/tests/test_config_loader.cpp
Objectives:
ConfigLoader::load(path)reads allXTypeConfigsections from TOML via toml++; missing keys fall back toXTypeConfig{}defaults without errorXTypeEngine::reloadConfig()override calls loader and re-applies every field- Legacy migration:
tab_accepts_word = false→partial_accept = falsewith a logged warning - 5 loader tests: missing file, malformed TOML, full round-trip, empty file, legacy migration
Outcome: Ghost text was simultaneously broken by empty stop-token strings being forwarded to Ollama. Fixed alongside E0: empty entries are now filtered from the stop-token list before payload construction. Also discovered that fcitx5-remote -r alone does not call reloadConfig(); the addon conf must declare Configurable=True for the reload signal to reach the engine.
Commit: feat(engine): load config.toml on startup and reload
Commit: fix(engine+settings): ghost text broken by empty stop token and reload wiring
Files: fcitx5-engine/src/xtype.cpp, fcitx5-engine/src/phrase_blocklist.h, fcitx5-engine/tests/test_phrase_blocklist.cpp, settings/qt6-app/qml/pages/PageGeneral.qml, settings/qt6-app/qml/pages/PageModel.qml
Objectives:
- Accept key made configurable: Enter and → (Right) both handled correctly in
keyEvent PhraseBlocklist: substring match, case-insensitive, reloaded onreloadConfig(); 9 unit tests including word-boundary cases- Threads slider wires
num_threadinto the Ollama payload when non-zero - Settings UI: removed
comingSoonguards from accept-key selector and threads slider; fixed threads slider QML binding (.validis always falsy on QML integers — replaced with?? 0null-coalescing)
Commit: feat(engine): wire AcceptKey::Enter and AcceptKey::Right in keyEvent
Commit: feat(engine): implement phrase blocklist substring matching
Commit: feat(engine): include num_thread in Ollama payload when threads is configured
Commit: feat(settings): remove comingSoon from accept-key Enter/→ and threads slider
Files: fcitx5-engine/src/xtype.cpp (metrics + events writers), fcitx5-engine/src/engine_metrics.h, fcitx5-engine/src/recent_events.h, settings/qt6-app/qml/pages/PageGeneral.qml
Objectives:
- Engine writes
~/.local/share/xtype/metrics.jsonandrecent_events.jsonatomically (.tmp→ rename) after each inference round-trip EngineProbein the settings app polls both files; sidebar shows live suggestions/min, average latency, corpus line count, and recent event list- PageGeneral live test input: replaced the static typewriter animation with a real
XInputfield that sends text to the running engine and displays its suggestion inline
Commit: feat(observability): bridge engine metrics + recent events to settings app
Commit: feat(settings): replace ghost-text typewriter with live test input on General page
Files: fcitx5-engine/src/xtype.cpp (buildSystemPrompt), settings/qt6-app/qml/pages/PagePersonalisation.qml
Objectives:
- Engine reads
user_prompt.description,user_prompt.tone, anduser_prompt.avoid_phrasesfrom config and injects them into the assembled system prompt - Description hard-capped at 500 chars in
applyPrompt()(engine) and viamaximumLengthon the QML textarea (settings) to protect the 2000-char prompt budget - Personalisation page: description textarea, tone segmented control, avoid-phrases pill list with add/remove
Commit: feat(engine): wire user_prompt config and voice_strength into system prompt
Commit: feat(settings): user voice profile UI — description, tone, avoid phrases
Files: fcitx5-engine/src/style_profile.cpp, settings/qt6-app/qml/pages/PagePersonalisation.qml
Objectives:
voice_strength(0.0–1.0) proportionally scales how many corpus-derived style exemplars are included in the system prompt: 0 suppresses all exemplars, 1 uses the full allocation- UI slider uses
onCommittedto debounce saves — no TOML write on every frame
Commit: feat(engine): proportional voice_strength exemplar control
Commit: feat(settings): enable voice-strength slider
Files: fcitx5-engine/src/corpus_collector.cpp, settings/qt6-app/qml/pages/PagePersonalisation.qml
Objectives:
forget_after_days: entries incorpus.txtolder than N days are pruned on each background flush; 0 disables pruningpruneOldEntries()rewrites only the lines that pass the age check — no full-file load- Personalisation page: toggle + day-count input wired through
ConfigStore
Commit: feat(engine): corpus rolling window for forget_after_days
Commit: feat(settings): enable forget-after-days toggle
Files: fcitx5-engine/src/xtype.cpp, settings/qt6-app/qml/pages/PagePerApp.qml, settings/qt6-app/qml/pages/PageModel.qml, settings/qt6-app/qml/pages/PagePersonalisation.qml, settings/qt6-app/src/config_store.h/.cpp
Objectives:
- Engine reads
[apps.X]TOML subtables and overlays per-app suggestion length and completion mode atactivate()time - Per-App page: each known app row exposes suggestion-length and mode controls
- Advanced inference knobs added to
ConfigStoreand wired throughPageModel: temperature, top_p, stop tokens - Corpus settings section added to
PagePersonalisation comingSoonguards removed from Trigger-mode and Quantisation controls (not yet implemented; controls removed entirely rather than left dimmed)
Commit: feat(engine): apply per-app config overrides in keyEvent and requestInference
Commit: feat(settings): enable per-app suggestion-length and mode controls
Commit: feat(settings): ConfigStore — temperature, top_p, stop_tokens, corpus knobs
Commit: feat(settings): PageModel advanced inference section — temperature, top_p, stop_tokens
Commit: feat(settings): PagePersonalisation corpus settings section
isBlockedword-boundary check: prevents partial app-name matches from over-blocking (e.g.konsoleno longer blocksorg.kde.konsole.desktopsubstrings unintentionally)- Typed-buffer overflow preserves sentence boundaries: when the 500-char buffer fills, the oldest whole sentence is dropped rather than splitting mid-word
- Inference log separated from
fcitx5.log: engine now writes to~/.local/share/xtype/inference.logso the settings appLogTailcan show inference activity independently - User description capped at 500 chars in both engine (
applyPrompt) and settings (maximumLength) to protect prompt budget
Commit: fix(engine): isBlocked word-boundary check prevents over-blocking
Commit: fix(engine): typed-buffer overflow preserves sentence boundaries
Commit: fix(engine): move inference log to ~/.local/share/xtype/inference.log
Commit: fix(engine/settings): cap user description at 500 chars to protect prompt budget