kon-coding-agent 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/PKG-INFO +7 -7
  2. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/README.md +6 -6
  3. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/pyproject.toml +1 -1
  4. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/turn.py +0 -1
  5. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/app.py +25 -11
  6. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/input.py +6 -25
  7. kon_coding_agent-0.2.3/src/kon/ui/prompt_history.py +98 -0
  8. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/ui/test_input_paste.py +1 -1
  9. kon_coding_agent-0.2.3/tests/ui/test_prompt_history.py +98 -0
  10. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/uv.lock +1 -1
  11. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/.gitignore +0 -0
  12. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/.kon/skills/kon-release-publish/SKILL.md +0 -0
  13. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/.kon/skills/kon-tmux-test/SKILL.md +0 -0
  14. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/.kon/skills/kon-tmux-test/run-e2e-tests.sh +0 -0
  15. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/.kon/skills/kon-tmux-test/setup-test-project.sh +0 -0
  16. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/.python-version +0 -0
  17. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/AGENTS.md +0 -0
  18. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/LICENSE +0 -0
  19. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/LOCAL.md +0 -0
  20. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/TODO.md +0 -0
  21. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/scripts/test_models.py +0 -0
  22. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/scripts/test_thinking_blocks.py +0 -0
  23. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/__init__.py +0 -0
  24. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/config.py +0 -0
  25. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/context/__init__.py +0 -0
  26. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/context/agents.py +0 -0
  27. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/context/loader.py +0 -0
  28. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/context/shared.py +0 -0
  29. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/context/skills.py +0 -0
  30. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/core/__init__.py +0 -0
  31. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/core/compaction.py +0 -0
  32. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/core/types.py +0 -0
  33. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/defaults/__init__.py +0 -0
  34. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/defaults/config.toml +0 -0
  35. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/events.py +0 -0
  36. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/__init__.py +0 -0
  37. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/base.py +0 -0
  38. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/models.py +0 -0
  39. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/oauth/__init__.py +0 -0
  40. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/oauth/copilot.py +0 -0
  41. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/oauth/openai.py +0 -0
  42. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/providers/__init__.py +0 -0
  43. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/providers/anthropic.py +0 -0
  44. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/providers/copilot.py +0 -0
  45. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/providers/copilot_anthropic.py +0 -0
  46. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/providers/github_copilot_headers.py +0 -0
  47. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/providers/mock.py +0 -0
  48. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/providers/openai_codex_responses.py +0 -0
  49. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/providers/openai_completions.py +0 -0
  50. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/providers/openai_responses.py +0 -0
  51. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/llm/providers/sanitize.py +0 -0
  52. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/loop.py +0 -0
  53. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/py.typed +0 -0
  54. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/session.py +0 -0
  55. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/shared.py +0 -0
  56. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/tools/__init__.py +0 -0
  57. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/tools/_read_image.py +0 -0
  58. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/tools/base.py +0 -0
  59. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/tools/bash.py +0 -0
  60. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/tools/edit.py +0 -0
  61. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/tools/find.py +0 -0
  62. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/tools/grep.py +0 -0
  63. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/tools/read.py +0 -0
  64. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/tools/write.py +0 -0
  65. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/tools_manager.py +0 -0
  66. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/__init__.py +0 -0
  67. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/app_protocol.py +0 -0
  68. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/autocomplete.py +0 -0
  69. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/blocks.py +0 -0
  70. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/chat.py +0 -0
  71. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/clipboard.py +0 -0
  72. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/commands.py +0 -0
  73. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/export.py +0 -0
  74. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/floating_list.py +0 -0
  75. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/formatting.py +0 -0
  76. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/path_complete.py +0 -0
  77. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/selection_mode.py +0 -0
  78. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/session_ui.py +0 -0
  79. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/styles.py +0 -0
  80. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/ui/widgets.py +0 -0
  81. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/src/kon/update_check.py +0 -0
  82. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/conftest.py +0 -0
  83. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/context/test_agents.py +0 -0
  84. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/context/test_skills.py +0 -0
  85. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/llm/__init__.py +0 -0
  86. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/llm/test_mock_provider.py +0 -0
  87. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/test_agentic_loop.py +0 -0
  88. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/test_cli_provider_resolution.py +0 -0
  89. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/test_compaction.py +0 -0
  90. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/test_config_binaries.py +0 -0
  91. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/test_config_error_fallback.py +0 -0
  92. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/test_config_injection.py +0 -0
  93. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/test_model_provider_resolution.py +0 -0
  94. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/test_session_persistence.py +0 -0
  95. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/test_system_prompt.py +0 -0
  96. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/test_update_check.py +0 -0
  97. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/test_update_notice_behavior.py +0 -0
  98. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/tools/test_diff.py +0 -0
  99. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/tools/test_edit.py +0 -0
  100. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/tools/test_read.py +0 -0
  101. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/tools/test_read_image.py +0 -0
  102. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/tools/test_read_image_integration.py +0 -0
  103. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/tools/test_write.py +0 -0
  104. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/ui/test_autocomplete.py +0 -0
  105. {kon_coding_agent-0.2.2 → kon_coding_agent-0.2.3}/tests/ui/test_floating_list.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kon-coding-agent
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Minimal coding agent
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -35,12 +35,9 @@ $ fd . | cut -d/ -f1 | sort | uniq -c | sort -rn
35
35
  108 kon
36
36
  ```
37
37
 
38
- ## Setup
39
-
40
- ### Warning
38
+ [Kon](https://bleach.fandom.com/wiki/Kon) is inspired from Bleach, a artificial soul
41
39
 
42
- > [!WARNING]
43
- > Platform support: macOS and Linux are supported; Windows is not tested yet.
40
+ ## Setup
44
41
 
45
42
  ### Prerequisites
46
43
 
@@ -57,11 +54,14 @@ This installs `kon` globally as a CLI tool.
57
54
  ### Install from source (advanced)
58
55
 
59
56
  ```bash
60
- git clone <repository-url>
57
+ git clone https://github.com/kuutsav/kon
61
58
  cd kon
62
59
  uv tool install .
63
60
  ```
64
61
 
62
+ > [!WARNING]
63
+ > Platform support: macOS and Linux are supported; Windows is not tested yet.
64
+
65
65
  ### Run
66
66
 
67
67
  ```bash
@@ -19,12 +19,9 @@ $ fd . | cut -d/ -f1 | sort | uniq -c | sort -rn
19
19
  108 kon
20
20
  ```
21
21
 
22
- ## Setup
23
-
24
- ### Warning
22
+ [Kon](https://bleach.fandom.com/wiki/Kon) is inspired from Bleach, a artificial soul
25
23
 
26
- > [!WARNING]
27
- > Platform support: macOS and Linux are supported; Windows is not tested yet.
24
+ ## Setup
28
25
 
29
26
  ### Prerequisites
30
27
 
@@ -41,11 +38,14 @@ This installs `kon` globally as a CLI tool.
41
38
  ### Install from source (advanced)
42
39
 
43
40
  ```bash
44
- git clone <repository-url>
41
+ git clone https://github.com/kuutsav/kon
45
42
  cd kon
46
43
  uv tool install .
47
44
  ```
48
45
 
46
+ > [!WARNING]
47
+ > Platform support: macOS and Linux are supported; Windows is not tested yet.
48
+
49
49
  ### Run
50
50
 
51
51
  ```bash
@@ -14,7 +14,7 @@ default = true
14
14
 
15
15
  [project]
16
16
  name = "kon-coding-agent"
17
- version = "0.2.2"
17
+ version = "0.2.3"
18
18
  description = "Minimal coding agent"
19
19
  readme = "README.md"
20
20
  requires-python = ">=3.12"
@@ -77,7 +77,6 @@ _TOOL_ARGS_TOKEN_CHUNK_UPDATE_INTERVAL = 4
77
77
 
78
78
 
79
79
  def _count_tokens(text: str) -> int:
80
- """Estimate token count from text (approx 4 chars per token)."""
81
80
  return len(text) // 4
82
81
 
83
82
 
@@ -11,7 +11,7 @@ from pathlib import Path
11
11
  from typing import ClassVar
12
12
 
13
13
  from rich.console import Console
14
- from textual import on
14
+ from textual import events, on
15
15
  from textual.app import App, ComposeResult
16
16
  from textual.binding import Binding
17
17
 
@@ -85,7 +85,7 @@ _PYPI_PACKAGE_NAME = _get_package_name()
85
85
  try:
86
86
  VERSION = version(_PYPI_PACKAGE_NAME)
87
87
  except PackageNotFoundError:
88
- VERSION = "0.2.2"
88
+ VERSION = "0.2.3"
89
89
 
90
90
  _COPILOT_API_TYPES: frozenset[ApiType] = frozenset(
91
91
  {ApiType.GITHUB_COPILOT, ApiType.GITHUB_COPILOT_RESPONSES, ApiType.ANTHROPIC_COPILOT}
@@ -192,6 +192,12 @@ class Kon(CommandsMixin, SessionUIMixin, App[None]):
192
192
  def _get_provider_api_type(self, provider: BaseProvider) -> ApiType:
193
193
  return _API_TYPE_BY_PROVIDER.get(type(provider), ApiType.OPENAI_COMPLETIONS)
194
194
 
195
+ @on(events.TextSelected)
196
+ def _on_text_selected(self) -> None:
197
+ selection = self.screen.get_selected_text()
198
+ if selection:
199
+ self.copy_to_clipboard(selection)
200
+
195
201
  def on_mount(self) -> None:
196
202
  if config.binaries.fd:
197
203
  self._fd_path = shutil.which("fd") or shutil.which("fdfind")
@@ -254,21 +260,25 @@ class Kon(CommandsMixin, SessionUIMixin, App[None]):
254
260
  thinking_level=self._thinking_level,
255
261
  )
256
262
 
263
+ provider_error: str | None = None
257
264
  try:
258
265
  self._provider = self._create_provider(api_type, provider_config)
259
266
  except ValueError as e:
260
- chat = self.query_one("#chat-log", ChatLog)
261
- chat.add_info_message(str(e), error=True)
262
- return
267
+ provider_error = str(e)
263
268
 
264
- valid_levels = self._provider.thinking_levels
265
- if self._thinking_level not in valid_levels:
266
- self._thinking_level = valid_levels[0] if valid_levels else "medium"
267
- self._provider.set_thinking_level(self._thinking_level)
269
+ if self._provider:
270
+ valid_levels = self._provider.thinking_levels
271
+ if self._thinking_level not in valid_levels:
272
+ self._thinking_level = valid_levels[0] if valid_levels else "medium"
273
+ self._provider.set_thinking_level(self._thinking_level)
268
274
 
269
275
  if not self._continue_recent and not self._resume_session:
270
276
  selected_model = get_model(self._model, self._model_provider)
271
- model_provider = selected_model.provider if selected_model else self._provider.name
277
+ model_provider = (
278
+ selected_model.provider
279
+ if selected_model
280
+ else (self._provider.name if self._provider else self._model_provider)
281
+ )
272
282
  self._model_provider = model_provider
273
283
  self._session = Session.create(
274
284
  self._cwd,
@@ -276,7 +286,8 @@ class Kon(CommandsMixin, SessionUIMixin, App[None]):
276
286
  model_id=self._model,
277
287
  thinking_level=self._thinking_level,
278
288
  )
279
- self._session.append_model_change(model_provider, self._model, base_url)
289
+ if model_provider:
290
+ self._session.append_model_change(model_provider, self._model, base_url)
280
291
 
281
292
  self._project_context = Context.load(self._cwd)
282
293
  # TODO: Surface self._project_context.skill_warnings in UI (e.g. chat info/error messages)
@@ -290,6 +301,9 @@ class Kon(CommandsMixin, SessionUIMixin, App[None]):
290
301
  skill_paths=[format_path(s.file_path) for s in self._project_context.skills],
291
302
  )
292
303
 
304
+ if provider_error:
305
+ chat.add_info_message(provider_error, error=True)
306
+
293
307
  for warning in consume_config_warnings():
294
308
  chat.add_info_message(warning, warning=True)
295
309
 
@@ -21,6 +21,7 @@ from .autocomplete import (
21
21
  )
22
22
  from .floating_list import ListItem
23
23
  from .path_complete import PathComplete
24
+ from .prompt_history import PromptHistory
24
25
 
25
26
  if TYPE_CHECKING:
26
27
  pass
@@ -93,9 +94,7 @@ class InputBox(Vertical):
93
94
  ) -> None:
94
95
  super().__init__(id=id, classes=classes)
95
96
  self._cwd = cwd or os.getcwd()
96
- self._history: list[str] = []
97
- self._history_index: int = -1
98
- self._history_temp: str = ""
97
+ self._history = PromptHistory()
99
98
 
100
99
  # Autocomplete providers
101
100
  self._slash_provider = SlashCommandProvider(DEFAULT_COMMANDS.copy())
@@ -460,33 +459,15 @@ class InputBox(Vertical):
460
459
  # -------------------------------------------------------------------------
461
460
 
462
461
  def _add_to_history(self, text: str) -> None:
463
- if text and (not self._history or self._history[-1] != text):
464
- self._history.append(text)
465
- self._history_index = -1
466
- self._history_temp = ""
462
+ self._history.append(text)
467
463
 
468
464
  def _history_navigate(self, direction: int) -> None:
469
- if not self._history:
470
- return
471
-
472
465
  textarea = self.query_one("#input-textarea", TextArea)
473
-
474
- if self._history_index == -1:
475
- self._history_temp = textarea.text
476
-
477
- new_index = self._history_index + direction
478
-
479
- if new_index < -1 or new_index >= len(self._history):
466
+ result = self._history.navigate(direction, textarea.text)
467
+ if result is None:
480
468
  return
481
-
482
- self._history_index = new_index
483
-
484
469
  textarea.clear()
485
- if self._history_index == -1:
486
- textarea.insert(self._history_temp)
487
- else:
488
- history_item = self._history[-(self._history_index + 1)]
489
- textarea.insert(history_item)
470
+ textarea.insert(result)
490
471
 
491
472
  # -------------------------------------------------------------------------
492
473
  # Messages
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from kon import CONFIG_DIR_NAME
7
+
8
+ MAX_HISTORY_ENTRIES = 50
9
+
10
+
11
+ def _history_path() -> Path:
12
+ return Path.home() / CONFIG_DIR_NAME / "prompt-history.jsonl"
13
+
14
+
15
+ class PromptHistory:
16
+ def __init__(self) -> None:
17
+ self._entries: list[str] = []
18
+ self._index: int = 0
19
+ self._draft: str = ""
20
+ self._load()
21
+
22
+ def _load(self) -> None:
23
+ path = _history_path()
24
+ if not path.exists():
25
+ return
26
+ try:
27
+ text = path.read_text(encoding="utf-8")
28
+ except OSError:
29
+ return
30
+ lines: list[str] = []
31
+ for line in text.strip().split("\n"):
32
+ if not line:
33
+ continue
34
+ try:
35
+ entry = json.loads(line)
36
+ except (json.JSONDecodeError, ValueError):
37
+ continue
38
+ if isinstance(entry, str) and entry:
39
+ lines.append(entry)
40
+ self._entries = lines[-MAX_HISTORY_ENTRIES:]
41
+ if len(lines) > MAX_HISTORY_ENTRIES:
42
+ self._rewrite()
43
+
44
+ def _rewrite(self) -> None:
45
+ path = _history_path()
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ try:
48
+ content = "\n".join(json.dumps(e) for e in self._entries) + "\n"
49
+ path.write_text(content, encoding="utf-8")
50
+ except OSError:
51
+ pass
52
+
53
+ def _append_to_file(self, entry: str) -> None:
54
+ path = _history_path()
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+ try:
57
+ with path.open("a", encoding="utf-8") as f:
58
+ f.write(json.dumps(entry) + "\n")
59
+ except OSError:
60
+ pass
61
+
62
+ def append(self, text: str) -> None:
63
+ if not text:
64
+ return
65
+ if self._entries and self._entries[-1] == text:
66
+ self._reset_index()
67
+ return
68
+ self._entries.append(text)
69
+ trimmed = len(self._entries) > MAX_HISTORY_ENTRIES
70
+ if trimmed:
71
+ self._entries = self._entries[-MAX_HISTORY_ENTRIES:]
72
+ self._rewrite()
73
+ else:
74
+ self._append_to_file(text)
75
+ self._reset_index()
76
+
77
+ def _reset_index(self) -> None:
78
+ self._index = 0
79
+ self._draft = ""
80
+
81
+ def navigate(self, direction: int, current_text: str) -> str | None:
82
+ if not self._entries:
83
+ return None
84
+
85
+ if self._index == 0:
86
+ self._draft = current_text
87
+
88
+ new_index = self._index + direction
89
+
90
+ if new_index > 0 or abs(new_index) > len(self._entries):
91
+ return None
92
+
93
+ self._index = new_index
94
+
95
+ if self._index == 0:
96
+ return self._draft
97
+
98
+ return self._entries[self._index]
@@ -70,4 +70,4 @@ def test_submit_keeps_display_text_but_expands_query_text() -> None:
70
70
  assert input_box._fake_textarea.cleared is True
71
71
  assert input_box._pastes == {}
72
72
  assert input_box._paste_counter == 0
73
- assert input_box._history[-1] == f"prefix {pasted} suffix"
73
+ assert input_box._history._entries[-1] == f"prefix {pasted} suffix"
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import pytest
6
+
7
+ from kon.ui import prompt_history as ph
8
+ from kon.ui.prompt_history import MAX_HISTORY_ENTRIES, PromptHistory
9
+
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def _isolate_history(tmp_path, monkeypatch):
13
+ history_file = tmp_path / "prompt-history.jsonl"
14
+ monkeypatch.setattr(ph, "_history_path", lambda: history_file)
15
+ return history_file
16
+
17
+
18
+ def test_append_and_navigate():
19
+ h = PromptHistory()
20
+ h.append("hello")
21
+ h.append("world")
22
+
23
+ assert h.navigate(-1, "") == "world"
24
+ assert h.navigate(-1, "world") == "hello"
25
+ assert h.navigate(1, "hello") == "world"
26
+ assert h.navigate(1, "world") == ""
27
+
28
+
29
+ def test_navigate_empty():
30
+ h = PromptHistory()
31
+ assert h.navigate(-1, "") is None
32
+
33
+
34
+ def test_navigate_preserves_draft():
35
+ h = PromptHistory()
36
+ h.append("old")
37
+
38
+ result = h.navigate(-1, "my draft")
39
+ assert result == "old"
40
+ result = h.navigate(1, "old")
41
+ assert result == "my draft"
42
+
43
+
44
+ def test_navigate_bounds():
45
+ h = PromptHistory()
46
+ h.append("only")
47
+
48
+ assert h.navigate(-1, "") == "only"
49
+ assert h.navigate(-1, "only") is None
50
+ h._reset_index()
51
+ assert h.navigate(1, "") is None
52
+
53
+
54
+ def test_dedup_consecutive():
55
+ h = PromptHistory()
56
+ h.append("same")
57
+ h.append("same")
58
+ assert len(h._entries) == 1
59
+
60
+
61
+ def test_persistence(tmp_path):
62
+ h1 = PromptHistory()
63
+ h1.append("first")
64
+ h1.append("second")
65
+
66
+ h2 = PromptHistory()
67
+ assert h2._entries == ["first", "second"]
68
+
69
+
70
+ def test_max_entries_trim(tmp_path):
71
+ h = PromptHistory()
72
+ for i in range(MAX_HISTORY_ENTRIES + 10):
73
+ h.append(f"entry-{i}")
74
+
75
+ assert len(h._entries) == MAX_HISTORY_ENTRIES
76
+ assert h._entries[0] == "entry-10"
77
+ assert h._entries[-1] == f"entry-{MAX_HISTORY_ENTRIES + 9}"
78
+
79
+ h2 = PromptHistory()
80
+ assert len(h2._entries) == MAX_HISTORY_ENTRIES
81
+
82
+
83
+ def test_corrupt_lines_ignored(tmp_path):
84
+ path = ph._history_path()
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ path.write_text(
87
+ json.dumps("good")
88
+ + "\n"
89
+ + "not valid json\n"
90
+ + json.dumps(42)
91
+ + "\n"
92
+ + json.dumps("also good")
93
+ + "\n",
94
+ encoding="utf-8",
95
+ )
96
+
97
+ h = PromptHistory()
98
+ assert h._entries == ["good", "also good"]
@@ -636,7 +636,7 @@ wheels = [
636
636
 
637
637
  [[package]]
638
638
  name = "kon-coding-agent"
639
- version = "0.2.2"
639
+ version = "0.2.3"
640
640
  source = { editable = "." }
641
641
  dependencies = [
642
642
  { name = "aiofiles" },