From 542ad7a494ae8c90aed7c8146e5bfb648abb3f98 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 09:04:56 +0800 Subject: [PATCH 01/14] Add slash commands (/status, /list, /lint, /add) to chat REPL Extract core logic from CLI commands into reusable functions (print_status, print_list, run_lint) so the chat REPL can call them directly instead of duplicating code. /add uses asyncio.to_thread since _add_single_file internally calls asyncio.run which cannot be nested. --- openkb/agent/chat.py | 57 ++++++++++++++++++++++++++++++++++++++++++++ openkb/cli.py | 55 ++++++++++++++++++++++++++---------------- 2 files changed, 92 insertions(+), 20 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 42ac9f9..dcb0096 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -46,6 +46,10 @@ " /exit Exit (Ctrl-D also works)\n" " /clear Start a fresh session (current one is kept on disk)\n" " /save [name] Export transcript to wiki/explorations/\n" + " /status Show knowledge base status\n" + " /list List all documents in the knowledge base\n" + " /lint Lint the knowledge base\n" + " /add Add a document or directory to the knowledge base\n" " /help Show this" ) @@ -271,6 +275,37 @@ def _save_transcript(kb_dir: Path, session: ChatSession, name: str | None) -> Pa return path +async def _run_add(arg: str, kb_dir: Path, style: Style) -> None: + """Add a document or directory to the knowledge base from the chat REPL.""" + import asyncio + from openkb.cli import _add_single_file, SUPPORTED_EXTENSIONS + + target = Path(arg).expanduser() + if not target.is_absolute(): + target = Path.cwd() / target + target = target.resolve() + + if not target.exists(): + _fmt(style, ("class:error", f"Path does not exist: {arg}\n")) + return + + if target.is_dir(): + files = [ + f for f in sorted(target.rglob("*")) + if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS + ] + if not files: + _fmt(style, ("class:error", f"No supported files found in {arg}.\n")) + return + for f in files: + await asyncio.to_thread(_add_single_file, f, kb_dir) + else: + if target.suffix.lower() not in SUPPORTED_EXTENSIONS: + _fmt(style, ("class:error", f"Unsupported file type: {target.suffix}\n")) + return + await asyncio.to_thread(_add_single_file, target, kb_dir) + + async def _handle_slash( cmd: str, kb_dir: Path, @@ -307,6 +342,28 @@ async def _handle_slash( _fmt(style, ("class:slash.ok", f"Saved to {path}\n")) return None + if head == "/status": + from openkb.cli import print_status + print_status(kb_dir) + return None + + if head == "/list": + from openkb.cli import print_list + print_list(kb_dir) + return None + + if head == "/lint": + from openkb.cli import run_lint + await run_lint(kb_dir) + return None + + if head == "/add": + if not arg: + _fmt(style, ("class:error", "Usage: /add \n")) + return None + await _run_add(arg, kb_dir, style) + return None + _fmt( style, ("class:error", f"Unknown command: {head}. Try /help.\n"), diff --git a/openkb/cli.py b/openkb/cli.py index d91789f..86a3563 100644 --- a/openkb/cli.py +++ b/openkb/cli.py @@ -525,18 +525,12 @@ def on_new_files(paths): watch_directory(raw_dir, on_new_files) -@cli.command() -@click.option("--fix", is_flag=True, default=False, help="Automatically fix lint issues (not yet implemented).") -@click.pass_context -def lint(ctx, fix): - """Lint the knowledge base for structural and semantic inconsistencies.""" - if fix: - click.echo("Warning: --fix is not yet implemented. Running lint in report-only mode.") - kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override")) - if kb_dir is None: - click.echo("No knowledge base found. Run `openkb init` first.") - return +async def run_lint(kb_dir: Path) -> Path: + """Run structural + knowledge lint, write report, return report path. + Async because knowledge lint uses an LLM agent. Usable from CLI + (via ``asyncio.run``) and directly from the chat REPL. + """ from openkb.lint import run_structural_lint from openkb.agent.linter import run_knowledge_lint @@ -556,15 +550,13 @@ def lint(ctx, fix): _setup_llm_key(kb_dir) model: str = config.get("model", DEFAULT_CONFIG["model"]) - # Structural lint click.echo("Running structural lint...") structural_report = run_structural_lint(kb_dir) click.echo(structural_report) - # Knowledge lint (semantic) click.echo("Running knowledge lint...") try: - knowledge_report = asyncio.run(run_knowledge_lint(kb_dir, model)) + knowledge_report = await run_knowledge_lint(kb_dir, model) except Exception as exc: knowledge_report = f"Knowledge lint failed: {exc}" click.echo(knowledge_report) @@ -579,17 +571,25 @@ def lint(ctx, fix): report_path.write_text(report_content, encoding="utf-8") append_log(kb_dir / "wiki", "lint", f"report → {report_path.name}") click.echo(f"\nReport written to {report_path}") + return report_path -@cli.command(name="list") +@cli.command() +@click.option("--fix", is_flag=True, default=False, help="Automatically fix lint issues (not yet implemented).") @click.pass_context -def list_cmd(ctx): - """List all documents in the knowledge base.""" +def lint(ctx, fix): + """Lint the knowledge base for structural and semantic inconsistencies.""" + if fix: + click.echo("Warning: --fix is not yet implemented. Running lint in report-only mode.") kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override")) if kb_dir is None: click.echo("No knowledge base found. Run `openkb init` first.") return + asyncio.run(run_lint(kb_dir)) + +def print_list(kb_dir: Path) -> None: + """Print all documents in the knowledge base. Usable from CLI and chat REPL.""" openkb_dir = kb_dir / ".openkb" hashes_file = openkb_dir / "hashes.json" if not hashes_file.exists(): @@ -642,15 +642,19 @@ def list_cmd(ctx): click.echo(f" - {r}") -@cli.command() +@cli.command(name="list") @click.pass_context -def status(ctx): - """Show the current status of the knowledge base.""" +def list_cmd(ctx): + """List all documents in the knowledge base.""" kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override")) if kb_dir is None: click.echo("No knowledge base found. Run `openkb init` first.") return + print_list(kb_dir) + +def print_status(kb_dir: Path) -> None: + """Print knowledge base status. Usable from CLI and chat REPL.""" wiki_dir = kb_dir / "wiki" subdirs = ["sources", "summaries", "concepts", "reports"] @@ -698,3 +702,14 @@ def status(ctx): import datetime mtime = datetime.datetime.fromtimestamp(newest_report.stat().st_mtime) click.echo(f" Last lint: {mtime.strftime('%Y-%m-%d %H:%M:%S')}") + + +@cli.command() +@click.pass_context +def status(ctx): + """Show the current status of the knowledge base.""" + kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override")) + if kb_dir is None: + click.echo("No knowledge base found. Run `openkb init` first.") + return + print_status(kb_dir) From 620f71486282d97c482b749827b1ab4bfa11b957 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 09:13:51 +0800 Subject: [PATCH 02/14] Address code review feedback for slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename _add_single_file → add_single_file (public API, consistent with print_status/print_list/run_lint already being public) - Add progress counter [i/total] for /add when processing directories - Move `import asyncio` to module-level imports in chat.py - Add 12 tests covering all new slash commands in _handle_slash --- openkb/agent/chat.py | 13 ++- openkb/cli.py | 8 +- tests/test_add_command.py | 6 +- tests/test_chat_slash_commands.py | 188 ++++++++++++++++++++++++++++++ 4 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 tests/test_chat_slash_commands.py diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index dcb0096..8b647a0 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import asyncio import os import re import sys @@ -277,8 +278,7 @@ def _save_transcript(kb_dir: Path, session: ChatSession, name: str | None) -> Pa async def _run_add(arg: str, kb_dir: Path, style: Style) -> None: """Add a document or directory to the knowledge base from the chat REPL.""" - import asyncio - from openkb.cli import _add_single_file, SUPPORTED_EXTENSIONS + from openkb.cli import add_single_file, SUPPORTED_EXTENSIONS target = Path(arg).expanduser() if not target.is_absolute(): @@ -297,13 +297,16 @@ async def _run_add(arg: str, kb_dir: Path, style: Style) -> None: if not files: _fmt(style, ("class:error", f"No supported files found in {arg}.\n")) return - for f in files: - await asyncio.to_thread(_add_single_file, f, kb_dir) + total = len(files) + _fmt(style, ("class:slash.help", f"Found {total} supported file(s) in {arg}.\n")) + for i, f in enumerate(files, 1): + _fmt(style, ("class:slash.help", f"\n[{i}/{total}] ")) + await asyncio.to_thread(add_single_file, f, kb_dir) else: if target.suffix.lower() not in SUPPORTED_EXTENSIONS: _fmt(style, ("class:error", f"Unsupported file type: {target.suffix}\n")) return - await asyncio.to_thread(_add_single_file, target, kb_dir) + await asyncio.to_thread(add_single_file, target, kb_dir) async def _handle_slash( diff --git a/openkb/cli.py b/openkb/cli.py index 86a3563..461b0c9 100644 --- a/openkb/cli.py +++ b/openkb/cli.py @@ -128,7 +128,7 @@ def _find_kb_dir(override: Path | None = None) -> Path | None: return None -def _add_single_file(file_path: Path, kb_dir: Path) -> None: +def add_single_file(file_path: Path, kb_dir: Path) -> None: """Convert, index, and compile a single document into the knowledge base. Steps: @@ -346,7 +346,7 @@ def add(ctx, path): click.echo(f"Found {total} supported file(s) in {path}.") for i, f in enumerate(files, 1): click.echo(f"\n[{i}/{total}] ", nl=False) - _add_single_file(f, kb_dir) + add_single_file(f, kb_dir) else: if target.suffix.lower() not in SUPPORTED_EXTENSIONS: click.echo( @@ -354,7 +354,7 @@ def add(ctx, path): f"Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}" ) return - _add_single_file(target, kb_dir) + add_single_file(target, kb_dir) @cli.command() @@ -519,7 +519,7 @@ def on_new_files(paths): f"Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}" ) continue - _add_single_file(fp, kb_dir) + add_single_file(fp, kb_dir) click.echo(f"Watching {raw_dir} for new documents. Press Ctrl+C to stop.") watch_directory(raw_dir, on_new_files) diff --git a/tests/test_add_command.py b/tests/test_add_command.py index 2ad22e7..5880ccb 100644 --- a/tests/test_add_command.py +++ b/tests/test_add_command.py @@ -63,13 +63,13 @@ def test_add_missing_init(self, tmp_path): result = runner.invoke(cli, ["add", "somefile.pdf"]) assert "No knowledge base found" in result.output - def test_add_single_file_calls_helper(self, tmp_path): + def testadd_single_file_calls_helper(self, tmp_path): kb_dir = self._setup_kb(tmp_path) doc = tmp_path / "test.md" doc.write_text("# Hello") runner = CliRunner() - with patch("openkb.cli._add_single_file") as mock_add, \ + with patch("openkb.cli.add_single_file") as mock_add, \ patch("openkb.cli._find_kb_dir", return_value=kb_dir): result = runner.invoke(cli, ["add", str(doc)]) mock_add.assert_called_once_with(doc, kb_dir) @@ -83,7 +83,7 @@ def test_add_directory_calls_helper_for_each_file(self, tmp_path): (docs_dir / "ignore.xyz").write_text("skip me") runner = CliRunner() - with patch("openkb.cli._add_single_file") as mock_add, \ + with patch("openkb.cli.add_single_file") as mock_add, \ patch("openkb.cli._find_kb_dir", return_value=kb_dir): result = runner.invoke(cli, ["add", str(docs_dir)]) # Should be called for .md and .txt but not .xyz diff --git a/tests/test_chat_slash_commands.py b/tests/test_chat_slash_commands.py new file mode 100644 index 0000000..57e20c0 --- /dev/null +++ b/tests/test_chat_slash_commands.py @@ -0,0 +1,188 @@ +"""Tests for slash commands in the chat REPL.""" +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from prompt_toolkit.styles import Style + +from openkb.agent.chat import _handle_slash, _run_add +from openkb.agent.chat_session import ChatSession + + +def _setup_kb(tmp_path: Path) -> Path: + """Create a minimal KB structure and return kb_dir.""" + kb_dir = tmp_path + (kb_dir / "raw").mkdir() + (kb_dir / "wiki" / "sources" / "images").mkdir(parents=True) + (kb_dir / "wiki" / "summaries").mkdir(parents=True) + (kb_dir / "wiki" / "concepts").mkdir(parents=True) + (kb_dir / "wiki" / "reports").mkdir(parents=True) + openkb_dir = kb_dir / ".openkb" + openkb_dir.mkdir() + (openkb_dir / "config.yaml").write_text("model: gpt-4o-mini\n") + (openkb_dir / "hashes.json").write_text(json.dumps({})) + return kb_dir + + +def _make_session(kb_dir: Path) -> ChatSession: + return ChatSession.new(kb_dir, "gpt-4o-mini", "en") + + +_STYLE = Style.from_dict({}) + + +def _collect_fmt(): + """Return (patch, collected) where collected is a list of printed strings.""" + collected: list[str] = [] + + def _fake_fmt(_style, *fragments): + for _cls, text in fragments: + collected.append(text) + + return patch("openkb.agent.chat._fmt", _fake_fmt), collected + + +# --- /status and /list use click.echo, captured by capsys --- + + +@pytest.mark.asyncio +async def test_slash_status(tmp_path, capsys): + kb_dir = _setup_kb(tmp_path) + session = _make_session(kb_dir) + result = await _handle_slash("/status", kb_dir, session, _STYLE) + assert result is None + output = capsys.readouterr().out + assert "Knowledge Base Status" in output + + +@pytest.mark.asyncio +async def test_slash_list_empty(tmp_path, capsys): + kb_dir = _setup_kb(tmp_path) + session = _make_session(kb_dir) + result = await _handle_slash("/list", kb_dir, session, _STYLE) + assert result is None + output = capsys.readouterr().out + assert "No documents indexed yet" in output + + +@pytest.mark.asyncio +async def test_slash_list_with_docs(tmp_path, capsys): + kb_dir = _setup_kb(tmp_path) + hashes = {"abc": {"name": "paper.pdf", "type": "pdf"}} + (kb_dir / ".openkb" / "hashes.json").write_text(json.dumps(hashes)) + session = _make_session(kb_dir) + result = await _handle_slash("/list", kb_dir, session, _STYLE) + assert result is None + output = capsys.readouterr().out + assert "paper.pdf" in output + + +# --- /add, /exit, /clear, /help, /unknown use _fmt → need patching --- + + +@pytest.mark.asyncio +async def test_slash_add_missing_arg(tmp_path): + kb_dir = _setup_kb(tmp_path) + session = _make_session(kb_dir) + p, collected = _collect_fmt() + with p: + result = await _handle_slash("/add", kb_dir, session, _STYLE) + assert result is None + assert any("Usage: /add " in s for s in collected) + + +@pytest.mark.asyncio +async def test_slash_add_nonexistent_path(tmp_path): + kb_dir = _setup_kb(tmp_path) + session = _make_session(kb_dir) + p, collected = _collect_fmt() + with p: + result = await _handle_slash("/add /no/such/path", kb_dir, session, _STYLE) + assert result is None + assert any("Path does not exist" in s for s in collected) + + +@pytest.mark.asyncio +async def test_slash_add_unsupported_type(tmp_path): + kb_dir = _setup_kb(tmp_path) + bad_file = tmp_path / "file.xyz" + bad_file.write_text("data") + session = _make_session(kb_dir) + p, collected = _collect_fmt() + with p: + result = await _handle_slash(f"/add {bad_file}", kb_dir, session, _STYLE) + assert result is None + assert any("Unsupported file type" in s for s in collected) + + +@pytest.mark.asyncio +async def test_slash_add_single_file(tmp_path): + kb_dir = _setup_kb(tmp_path) + doc = tmp_path / "test.md" + doc.write_text("# Hello") + p, _collected = _collect_fmt() + with p, patch("openkb.cli.add_single_file") as mock_add: + await _run_add(str(doc), kb_dir, _STYLE) + mock_add.assert_called_once_with(doc, kb_dir) + + +@pytest.mark.asyncio +async def test_slash_add_directory_with_progress(tmp_path): + kb_dir = _setup_kb(tmp_path) + docs_dir = tmp_path / "docs" + docs_dir.mkdir() + (docs_dir / "a.md").write_text("# A") + (docs_dir / "b.txt").write_text("B") + (docs_dir / "skip.xyz").write_text("skip") + p, collected = _collect_fmt() + with p, patch("openkb.cli.add_single_file") as mock_add: + await _run_add(str(docs_dir), kb_dir, _STYLE) + assert mock_add.call_count == 2 + output = "".join(collected) + assert "Found 2 supported file(s)" in output + assert "[1/2]" in output + assert "[2/2]" in output + + +@pytest.mark.asyncio +async def test_slash_lint(tmp_path): + kb_dir = _setup_kb(tmp_path) + session = _make_session(kb_dir) + with patch("openkb.cli.run_lint", new_callable=AsyncMock, return_value=tmp_path / "report.md"): + result = await _handle_slash("/lint", kb_dir, session, _STYLE) + assert result is None + + +@pytest.mark.asyncio +async def test_slash_unknown(tmp_path): + kb_dir = _setup_kb(tmp_path) + session = _make_session(kb_dir) + p, collected = _collect_fmt() + with p: + result = await _handle_slash("/foobar", kb_dir, session, _STYLE) + assert result is None + assert any("Unknown command" in s for s in collected) + + +@pytest.mark.asyncio +async def test_slash_exit(tmp_path): + kb_dir = _setup_kb(tmp_path) + session = _make_session(kb_dir) + p, _collected = _collect_fmt() + with p: + result = await _handle_slash("/exit", kb_dir, session, _STYLE) + assert result == "exit" + + +@pytest.mark.asyncio +async def test_slash_clear(tmp_path): + kb_dir = _setup_kb(tmp_path) + session = _make_session(kb_dir) + p, _collected = _collect_fmt() + with p: + result = await _handle_slash("/clear", kb_dir, session, _STYLE) + assert result == "new_session" From b85e3fb96ce8ad2b5cec3ff11891b9e81cc1a09e Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 09:50:58 +0800 Subject: [PATCH 03/14] Strip surrounding quotes from slash command arguments When users type /add '/path/to file' the quotes were included as part of the path, causing "Path does not exist" errors. Now single and double quotes wrapping the argument are stripped. --- openkb/agent/chat.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 8b647a0..42d37d7 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -320,6 +320,9 @@ async def _handle_slash( parts = cmd.split(maxsplit=1) head = parts[0].lower() arg = parts[1].strip() if len(parts) > 1 else "" + # Strip surrounding quotes (user may type /add '/path/to file') + if len(arg) >= 2 and arg[0] == arg[-1] and arg[0] in ("'", '"'): + arg = arg[1:-1] if head in ("/exit", "/quit"): _fmt(style, ("class:header", "Bye. Thanks for using OpenKB.\n\n")) From f364b3e81e6452e5332504cbbb3bdf521367b4b3 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 09:54:31 +0800 Subject: [PATCH 04/14] Add tab completion for slash commands and file paths - Tab on / prefix completes slash commands (/status, /list, etc.) - Tab after /add completes file paths with expanduser support - Handles quoted paths (strips leading quote before path completion) --- openkb/agent/chat.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 42d37d7..0986f18 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -16,6 +16,8 @@ from typing import Any from prompt_toolkit import PromptSession +from prompt_toolkit.completion import Completer, Completion, PathCompleter +from prompt_toolkit.document import Document from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.shortcuts import print_formatted_text from prompt_toolkit.styles import Style @@ -186,10 +188,46 @@ def _bottom_toolbar(session: ChatSession) -> FormattedText: ) +_SLASH_COMMANDS = [ + "/exit", "/quit", "/help", "/clear", "/save", + "/status", "/list", "/lint", "/add", +] + + +class _ChatCompleter(Completer): + """Complete slash commands and file paths after /add.""" + + def __init__(self) -> None: + self._path_completer = PathCompleter(expanduser=True) + + def get_completions(self, document: Document, complete_event: Any) -> Any: + text = document.text_before_cursor + + # After "/add ", complete file paths + if text.lstrip().lower().startswith("/add "): + # Extract the path portion after "/add " + prefix = text.lstrip() + add_pos = prefix.lower().index("/add ") + 5 + path_text = prefix[add_pos:] + # Strip surrounding quotes if the user started one + if path_text and path_text[0] in ("'", '"'): + path_text = path_text[1:] + path_doc = Document(path_text, len(path_text)) + yield from self._path_completer.get_completions(path_doc, complete_event) + return + + # Complete slash commands + if text.startswith("/"): + for cmd in _SLASH_COMMANDS: + if cmd.startswith(text.lower()): + yield Completion(cmd, start_position=-len(text)) + + def _make_prompt_session(session: ChatSession, style: Style, use_color: bool) -> PromptSession: return PromptSession( message=FormattedText([("class:prompt", ">>> ")]), style=style, + completer=_ChatCompleter(), bottom_toolbar=(lambda: _bottom_toolbar(session)) if use_color else None, ) From 09743c284b57d31aff0bcc78a176840a822d4e69 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 09:57:57 +0800 Subject: [PATCH 05/14] Persist chat input history across sessions using FileHistory Store prompt history in .openkb/chat_history so users can press up/down arrows to recall previous inputs across chat sessions. --- openkb/agent/chat.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 0986f18..3c1ebb1 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -223,11 +223,15 @@ def get_completions(self, document: Document, complete_event: Any) -> Any: yield Completion(cmd, start_position=-len(text)) -def _make_prompt_session(session: ChatSession, style: Style, use_color: bool) -> PromptSession: +def _make_prompt_session(session: ChatSession, style: Style, use_color: bool, kb_dir: Path) -> PromptSession: + from prompt_toolkit.history import FileHistory + + history_path = kb_dir / ".openkb" / "chat_history" return PromptSession( message=FormattedText([("class:prompt", ">>> ")]), style=style, completer=_ChatCompleter(), + history=FileHistory(str(history_path)), bottom_toolbar=(lambda: _bottom_toolbar(session)) if use_color else None, ) @@ -436,7 +440,7 @@ async def run_chat( if session.turn_count > 0: _print_resume_view(session, style) - prompt_session = _make_prompt_session(session, style, use_color) + prompt_session = _make_prompt_session(session, style, use_color, kb_dir) last_sigint = 0.0 @@ -467,7 +471,7 @@ async def run_chat( if action == "new_session": session = ChatSession.new(kb_dir, session.model, session.language) agent = build_query_agent(wiki_root, session.model, language=language) - prompt_session = _make_prompt_session(session, style, use_color) + prompt_session = _make_prompt_session(session, style, use_color, kb_dir) continue append_log(kb_dir / "wiki", "query", user_input) From 1969424c364da415094f655fea5460f831c5f092 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 10:11:56 +0800 Subject: [PATCH 06/14] Add descriptions to slash command completions and use multi-column style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show command descriptions next to completions (e.g. /status → "Show knowledge base status") - Switch completion display to MULTI_COLUMN (zsh-like, listed below the prompt instead of a dropdown menu) --- openkb/agent/chat.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 3c1ebb1..b1830f7 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -19,7 +19,7 @@ from prompt_toolkit.completion import Completer, Completion, PathCompleter from prompt_toolkit.document import Document from prompt_toolkit.formatted_text import FormattedText -from prompt_toolkit.shortcuts import print_formatted_text +from prompt_toolkit.shortcuts import CompleteStyle, print_formatted_text from prompt_toolkit.styles import Style from openkb.agent.chat_session import ChatSession @@ -188,9 +188,16 @@ def _bottom_toolbar(session: ChatSession) -> FormattedText: ) -_SLASH_COMMANDS = [ - "/exit", "/quit", "/help", "/clear", "/save", - "/status", "/list", "/lint", "/add", +_SLASH_COMMANDS: list[tuple[str, str]] = [ + ("/exit", "Exit (Ctrl-D also works)"), + ("/quit", "Exit (alias)"), + ("/help", "Show available commands"), + ("/clear", "Start a fresh session"), + ("/save", "Export transcript to wiki/explorations/"), + ("/status", "Show knowledge base status"), + ("/list", "List all documents"), + ("/lint", "Lint the knowledge base"), + ("/add", "Add a document or directory"), ] @@ -205,22 +212,19 @@ def get_completions(self, document: Document, complete_event: Any) -> Any: # After "/add ", complete file paths if text.lstrip().lower().startswith("/add "): - # Extract the path portion after "/add " - prefix = text.lstrip() - add_pos = prefix.lower().index("/add ") + 5 - path_text = prefix[add_pos:] - # Strip surrounding quotes if the user started one + path_text = text.lstrip()[5:] + # Strip leading quote if user started one if path_text and path_text[0] in ("'", '"'): path_text = path_text[1:] path_doc = Document(path_text, len(path_text)) yield from self._path_completer.get_completions(path_doc, complete_event) return - # Complete slash commands + # Complete slash commands with descriptions if text.startswith("/"): - for cmd in _SLASH_COMMANDS: + for cmd, desc in _SLASH_COMMANDS: if cmd.startswith(text.lower()): - yield Completion(cmd, start_position=-len(text)) + yield Completion(cmd, start_position=-len(text), display_meta=desc) def _make_prompt_session(session: ChatSession, style: Style, use_color: bool, kb_dir: Path) -> PromptSession: @@ -231,6 +235,7 @@ def _make_prompt_session(session: ChatSession, style: Style, use_color: bool, kb message=FormattedText([("class:prompt", ">>> ")]), style=style, completer=_ChatCompleter(), + complete_style=CompleteStyle.MULTI_COLUMN, history=FileHistory(str(history_path)), bottom_toolbar=(lambda: _bottom_toolbar(session)) if use_color else None, ) From e39cc354a01f84f252bf2824275e9fa2727f0e30 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 10:15:31 +0800 Subject: [PATCH 07/14] Hide dotfiles in path completion and restyle completion menu - Filter out dotfiles from /add path completion unless the user explicitly types a dot prefix (e.g. /add ./. will show dotfiles) - Restyle completion menu: transparent background, subtle text colors, no heavy inverted bar --- openkb/agent/chat.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index b1830f7..3ebcfbb 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -42,6 +42,12 @@ "resume.turn": "#5fa0e0", "resume.user": "bold", "resume.assistant": "#8a8a8a", + # Completion menu — lightweight, no heavy background + "completion-menu": "bg:default #8a8a8a", + "completion-menu.completion": "bg:default #d0d0d0", + "completion-menu.completion.current": "bg:#3a3a3a #ffffff bold", + "completion-menu.meta.completion": "bg:default #6a6a6a", + "completion-menu.meta.completion.current": "bg:#3a3a3a #8a8a8a", } _HELP_TEXT = ( @@ -210,14 +216,19 @@ def __init__(self) -> None: def get_completions(self, document: Document, complete_event: Any) -> Any: text = document.text_before_cursor - # After "/add ", complete file paths + # After "/add ", complete file paths (skip dotfiles) if text.lstrip().lower().startswith("/add "): path_text = text.lstrip()[5:] # Strip leading quote if user started one if path_text and path_text[0] in ("'", '"'): path_text = path_text[1:] path_doc = Document(path_text, len(path_text)) - yield from self._path_completer.get_completions(path_doc, complete_event) + for c in self._path_completer.get_completions(path_doc, complete_event): + # Hide dotfiles unless the user explicitly typed a dot + basename = c.text.lstrip("/") + if basename.startswith(".") and not path_text.rpartition("/")[2].startswith("."): + continue + yield c return # Complete slash commands with descriptions From 28fbbbd106a87d97e0833adbc374d9c426e291a8 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 10:16:22 +0800 Subject: [PATCH 08/14] Use Tab-only completion trigger (zsh-style) Disable complete_while_typing so completions only appear on Tab press. Subsequent Tabs cycle through candidates, like zsh. --- openkb/agent/chat.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 3ebcfbb..22788a5 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -247,6 +247,7 @@ def _make_prompt_session(session: ChatSession, style: Style, use_color: bool, kb style=style, completer=_ChatCompleter(), complete_style=CompleteStyle.MULTI_COLUMN, + complete_while_typing=False, history=FileHistory(str(history_path)), bottom_toolbar=(lambda: _bottom_toolbar(session)) if use_color else None, ) From db5a03bbeac7ee9fd2b3183a7399f76b9368f963 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 10:20:07 +0800 Subject: [PATCH 09/14] Make Tab accept completion selection instead of cycling Custom key bindings so that: - Tab with no menu open: trigger completion - Arrow keys: navigate candidates in the menu - Tab with menu open: accept current selection and insert --- openkb/agent/chat.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 22788a5..bec1e33 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -239,7 +239,24 @@ def get_completions(self, document: Document, complete_event: Any) -> Any: def _make_prompt_session(session: ChatSession, style: Style, use_color: bool, kb_dir: Path) -> PromptSession: + from prompt_toolkit.filters import has_completions from prompt_toolkit.history import FileHistory + from prompt_toolkit.key_binding import KeyBindings + + kb = KeyBindings() + + @kb.add("tab", filter=has_completions) + def _accept_completion(event: Any) -> None: + """Tab accepts the current completion (like zsh), not cycle.""" + buf = event.current_buffer + if buf.complete_state: + buf.apply_completion(buf.complete_state.current_completion) + + @kb.add("tab", filter=~has_completions) + def _trigger_completion(event: Any) -> None: + """Tab triggers completion when menu is not open.""" + buf = event.current_buffer + buf.start_completion() history_path = kb_dir / ".openkb" / "chat_history" return PromptSession( @@ -248,6 +265,7 @@ def _make_prompt_session(session: ChatSession, style: Style, use_color: bool, kb completer=_ChatCompleter(), complete_style=CompleteStyle.MULTI_COLUMN, complete_while_typing=False, + key_bindings=kb, history=FileHistory(str(history_path)), bottom_toolbar=(lambda: _bottom_toolbar(session)) if use_color else None, ) From 7cfa76235009686ff01072cc6c95467eb16e6046 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 10:21:25 +0800 Subject: [PATCH 10/14] Fix Tab crash when no completion is selected yet Guard against current_completion being None when the menu is open but no item is highlighted. Falls back to selecting the first item. --- openkb/agent/chat.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index bec1e33..babe5f5 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -249,8 +249,12 @@ def _make_prompt_session(session: ChatSession, style: Style, use_color: bool, kb def _accept_completion(event: Any) -> None: """Tab accepts the current completion (like zsh), not cycle.""" buf = event.current_buffer - if buf.complete_state: - buf.apply_completion(buf.complete_state.current_completion) + state = buf.complete_state + if state and state.current_completion: + buf.apply_completion(state.current_completion) + else: + # Menu open but nothing selected yet — select first item + buf.go_to_completion(0) @kb.add("tab", filter=~has_completions) def _trigger_completion(event: Any) -> None: From adbb20063e343c5c267957131c8724ad57830435 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 10:21:58 +0800 Subject: [PATCH 11/14] Auto-accept single completion candidate on Tab When only one candidate matches, Tab inserts it directly without requiring arrow key selection first. --- openkb/agent/chat.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index babe5f5..6ddc273 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -250,10 +250,15 @@ def _accept_completion(event: Any) -> None: """Tab accepts the current completion (like zsh), not cycle.""" buf = event.current_buffer state = buf.complete_state - if state and state.current_completion: + if not state: + return + # Only one candidate or already selected — accept immediately + if state.current_completion: buf.apply_completion(state.current_completion) + elif len(state.completions) == 1: + buf.apply_completion(state.completions[0]) else: - # Menu open but nothing selected yet — select first item + # Multiple candidates, nothing selected — highlight first buf.go_to_completion(0) @kb.add("tab", filter=~has_completions) From 0758b7536ebddcd900692ff8e225e4a4fb481254 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 10:41:16 +0800 Subject: [PATCH 12/14] Fix run_lint return type annotation to Path | None --- openkb/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openkb/cli.py b/openkb/cli.py index 461b0c9..658b378 100644 --- a/openkb/cli.py +++ b/openkb/cli.py @@ -525,9 +525,10 @@ def on_new_files(paths): watch_directory(raw_dir, on_new_files) -async def run_lint(kb_dir: Path) -> Path: +async def run_lint(kb_dir: Path) -> Path | None: """Run structural + knowledge lint, write report, return report path. + Returns ``None`` if the KB has no indexed documents (nothing to lint). Async because knowledge lint uses an LLM agent. Usable from CLI (via ``asyncio.run``) and directly from the chat REPL. """ From 1a46adb7e017b22f3e1143c67d1d184505078ca7 Mon Sep 17 00:00:00 2001 From: Ray Date: Sun, 12 Apr 2026 16:07:36 +0800 Subject: [PATCH 13/14] Fix test naming typo and handle Ctrl-C in slash commands - Restore underscore in test_add_single_file_calls_helper (accidental removal during _add_single_file rename) - Catch KeyboardInterrupt around _handle_slash so Ctrl-C during /add or /lint aborts the command instead of triggering the exit prompt - Add test for Ctrl-C behavior during slash command execution --- openkb/agent/chat.py | 6 +++++- tests/test_add_command.py | 2 +- tests/test_chat_slash_commands.py | 32 ++++++++++++++++++++++++++++++- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 6ddc273..64e5b72 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -509,7 +509,11 @@ async def run_chat( continue if user_input.startswith("/"): - action = await _handle_slash(user_input, kb_dir, session, style) + try: + action = await _handle_slash(user_input, kb_dir, session, style) + except KeyboardInterrupt: + _fmt(style, ("class:error", "\n[aborted]\n")) + continue if action == "exit": return if action == "new_session": diff --git a/tests/test_add_command.py b/tests/test_add_command.py index 5880ccb..8d62ea2 100644 --- a/tests/test_add_command.py +++ b/tests/test_add_command.py @@ -63,7 +63,7 @@ def test_add_missing_init(self, tmp_path): result = runner.invoke(cli, ["add", "somefile.pdf"]) assert "No knowledge base found" in result.output - def testadd_single_file_calls_helper(self, tmp_path): + def test_add_single_file_calls_helper(self, tmp_path): kb_dir = self._setup_kb(tmp_path) doc = tmp_path / "test.md" doc.write_text("# Hello") diff --git a/tests/test_chat_slash_commands.py b/tests/test_chat_slash_commands.py index 57e20c0..5633790 100644 --- a/tests/test_chat_slash_commands.py +++ b/tests/test_chat_slash_commands.py @@ -9,7 +9,7 @@ from prompt_toolkit.styles import Style -from openkb.agent.chat import _handle_slash, _run_add +from openkb.agent.chat import _handle_slash, _run_add, run_chat from openkb.agent.chat_session import ChatSession @@ -157,6 +157,36 @@ async def test_slash_lint(tmp_path): assert result is None +@pytest.mark.asyncio +async def test_run_chat_handles_ctrl_c_during_slash_command(tmp_path): + kb_dir = _setup_kb(tmp_path) + session = _make_session(kb_dir) + + class _FakePromptSession: + def __init__(self) -> None: + self.calls = 0 + + async def prompt_async(self) -> str: + self.calls += 1 + if self.calls == 1: + return "/lint" + raise EOFError + + prompt = _FakePromptSession() + p, collected = _collect_fmt() + with ( + p, + patch("openkb.agent.chat.build_query_agent", return_value=object()), + patch("openkb.agent.chat._print_header"), + patch("openkb.agent.chat._make_prompt_session", return_value=prompt), + patch("openkb.agent.chat._handle_slash", new_callable=AsyncMock, side_effect=KeyboardInterrupt), + ): + await run_chat(kb_dir, session, no_color=True) + + assert prompt.calls == 2 + assert any("[aborted]" in s for s in collected) + + @pytest.mark.asyncio async def test_slash_unknown(tmp_path): kb_dir = _setup_kb(tmp_path) From 2e289dde698db81a378ec44696477fdd8f25e33e Mon Sep 17 00:00:00 2001 From: Ray Date: Sun, 12 Apr 2026 16:20:49 +0800 Subject: [PATCH 14/14] Fix /add tab completion with quoted paths - Append closing quote for file completions so quotes stay paired - Skip closing quote for directory completions to allow continued navigation - Fallback strip unmatched leading quote in _handle_slash --- openkb/agent/chat.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 64e5b72..6dd2ec7 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -219,8 +219,10 @@ def get_completions(self, document: Document, complete_event: Any) -> Any: # After "/add ", complete file paths (skip dotfiles) if text.lstrip().lower().startswith("/add "): path_text = text.lstrip()[5:] - # Strip leading quote if user started one + # Strip leading quote so PathCompleter resolves the real path + quote_char = "" if path_text and path_text[0] in ("'", '"'): + quote_char = path_text[0] path_text = path_text[1:] path_doc = Document(path_text, len(path_text)) for c in self._path_completer.get_completions(path_doc, complete_event): @@ -228,7 +230,18 @@ def get_completions(self, document: Document, complete_event: Any) -> Any: basename = c.text.lstrip("/") if basename.startswith(".") and not path_text.rpartition("/")[2].startswith("."): continue - yield c + # Append closing quote for files; skip for directories so + # the user can keep navigating into subdirectories. + if quote_char and not c.text.endswith("/"): + comp_text = c.text + quote_char + else: + comp_text = c.text + yield Completion( + comp_text, + start_position=c.start_position, + display=c.display, + display_meta=c.display_meta, + ) return # Complete slash commands with descriptions @@ -409,6 +422,8 @@ async def _handle_slash( # Strip surrounding quotes (user may type /add '/path/to file') if len(arg) >= 2 and arg[0] == arg[-1] and arg[0] in ("'", '"'): arg = arg[1:-1] + elif arg and arg[0] in ("'", '"'): + arg = arg[1:] if head in ("/exit", "/quit"): _fmt(style, ("class:header", "Bye. Thanks for using OpenKB.\n\n"))