klaude-code 1.2.12__py3-none-any.whl → 1.2.14__py3-none-any.whl
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.
- klaude_code/auth/codex/oauth.py +3 -3
- klaude_code/cli/auth_cmd.py +73 -0
- klaude_code/cli/config_cmd.py +88 -0
- klaude_code/cli/debug.py +72 -0
- klaude_code/cli/main.py +31 -142
- klaude_code/cli/runtime.py +19 -58
- klaude_code/cli/session_cmd.py +9 -9
- klaude_code/command/__init__.py +6 -6
- klaude_code/command/export_cmd.py +3 -3
- klaude_code/command/model_cmd.py +1 -1
- klaude_code/command/registry.py +1 -1
- klaude_code/command/terminal_setup_cmd.py +2 -2
- klaude_code/command/thinking_cmd.py +8 -6
- klaude_code/config/__init__.py +1 -5
- klaude_code/config/config.py +31 -4
- klaude_code/config/list_model.py +1 -1
- klaude_code/const/__init__.py +8 -3
- klaude_code/core/agent.py +14 -62
- klaude_code/core/executor.py +11 -10
- klaude_code/core/manager/agent_manager.py +4 -4
- klaude_code/core/manager/llm_clients.py +10 -49
- klaude_code/core/manager/llm_clients_builder.py +8 -21
- klaude_code/core/manager/sub_agent_manager.py +3 -3
- klaude_code/core/prompt.py +12 -7
- klaude_code/core/reminders.py +1 -1
- klaude_code/core/task.py +2 -2
- klaude_code/core/tool/__init__.py +16 -25
- klaude_code/core/tool/file/_utils.py +1 -1
- klaude_code/core/tool/file/apply_patch.py +17 -25
- klaude_code/core/tool/file/apply_patch_tool.py +4 -7
- klaude_code/core/tool/file/edit_tool.py +4 -11
- klaude_code/core/tool/file/multi_edit_tool.py +2 -3
- klaude_code/core/tool/file/read_tool.py +3 -4
- klaude_code/core/tool/file/write_tool.py +2 -3
- klaude_code/core/tool/memory/memory_tool.py +2 -8
- klaude_code/core/tool/memory/skill_loader.py +3 -2
- klaude_code/core/tool/shell/command_safety.py +0 -1
- klaude_code/core/tool/tool_context.py +1 -3
- klaude_code/core/tool/tool_registry.py +2 -1
- klaude_code/core/tool/tool_runner.py +1 -1
- klaude_code/core/tool/truncation.py +2 -5
- klaude_code/core/turn.py +9 -3
- klaude_code/llm/anthropic/client.py +6 -2
- klaude_code/llm/client.py +5 -1
- klaude_code/llm/codex/client.py +2 -2
- klaude_code/llm/input_common.py +2 -2
- klaude_code/llm/openai_compatible/client.py +11 -8
- klaude_code/llm/openai_compatible/stream_processor.py +2 -1
- klaude_code/llm/openrouter/client.py +22 -9
- klaude_code/llm/openrouter/reasoning_handler.py +19 -132
- klaude_code/llm/registry.py +6 -5
- klaude_code/llm/responses/client.py +10 -5
- klaude_code/protocol/events.py +9 -2
- klaude_code/protocol/model.py +7 -1
- klaude_code/protocol/sub_agent.py +2 -2
- klaude_code/session/export.py +58 -0
- klaude_code/session/selector.py +2 -2
- klaude_code/session/session.py +37 -7
- klaude_code/session/templates/export_session.html +46 -0
- klaude_code/trace/__init__.py +2 -2
- klaude_code/trace/log.py +144 -5
- klaude_code/ui/__init__.py +4 -9
- klaude_code/ui/core/stage_manager.py +7 -4
- klaude_code/ui/modes/debug/display.py +2 -1
- klaude_code/ui/modes/repl/__init__.py +1 -1
- klaude_code/ui/modes/repl/completers.py +6 -7
- klaude_code/ui/modes/repl/display.py +3 -4
- klaude_code/ui/modes/repl/event_handler.py +63 -5
- klaude_code/ui/modes/repl/key_bindings.py +2 -3
- klaude_code/ui/modes/repl/renderer.py +52 -62
- klaude_code/ui/renderers/diffs.py +1 -4
- klaude_code/ui/renderers/tools.py +4 -0
- klaude_code/ui/rich/markdown.py +3 -3
- klaude_code/ui/rich/searchable_text.py +6 -6
- klaude_code/ui/rich/status.py +3 -4
- klaude_code/ui/rich/theme.py +2 -5
- klaude_code/ui/terminal/control.py +7 -16
- klaude_code/ui/terminal/notifier.py +2 -4
- klaude_code/ui/utils/common.py +1 -1
- klaude_code/ui/utils/debouncer.py +2 -2
- {klaude_code-1.2.12.dist-info → klaude_code-1.2.14.dist-info}/METADATA +1 -1
- {klaude_code-1.2.12.dist-info → klaude_code-1.2.14.dist-info}/RECORD +84 -81
- {klaude_code-1.2.12.dist-info → klaude_code-1.2.14.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.12.dist-info → klaude_code-1.2.14.dist-info}/entry_points.txt +0 -0
klaude_code/session/session.py
CHANGED
|
@@ -261,15 +261,16 @@ class Session(BaseModel):
|
|
|
261
261
|
return False
|
|
262
262
|
if prev_item is None:
|
|
263
263
|
return True
|
|
264
|
-
|
|
264
|
+
return isinstance(
|
|
265
265
|
prev_item,
|
|
266
266
|
model.UserMessageItem | model.ToolResultItem | model.DeveloperMessageItem,
|
|
267
|
-
)
|
|
268
|
-
return True
|
|
269
|
-
return False
|
|
267
|
+
)
|
|
270
268
|
|
|
271
269
|
def get_history_item(self) -> Iterable[events.HistoryItemEvent]:
|
|
270
|
+
seen_sub_agent_sessions: set[str] = set()
|
|
272
271
|
prev_item: model.ConversationItem | None = None
|
|
272
|
+
last_assistant_content: str = ""
|
|
273
|
+
yield events.TaskStartEvent(session_id=self.id, sub_agent_state=self.sub_agent_state)
|
|
273
274
|
for it in self.conversation_history:
|
|
274
275
|
if self.need_turn_start(prev_item, it):
|
|
275
276
|
yield events.TurnStartEvent(
|
|
@@ -278,6 +279,7 @@ class Session(BaseModel):
|
|
|
278
279
|
match it:
|
|
279
280
|
case model.AssistantMessageItem() as am:
|
|
280
281
|
content = am.content or ""
|
|
282
|
+
last_assistant_content = content
|
|
281
283
|
yield events.AssistantMessageEvent(
|
|
282
284
|
content=content,
|
|
283
285
|
response_id=am.response_id,
|
|
@@ -290,7 +292,6 @@ class Session(BaseModel):
|
|
|
290
292
|
arguments=tc.arguments,
|
|
291
293
|
response_id=tc.response_id,
|
|
292
294
|
session_id=self.id,
|
|
293
|
-
is_replay=True,
|
|
294
295
|
)
|
|
295
296
|
case model.ToolResultItem() as tr:
|
|
296
297
|
yield events.ToolResultEvent(
|
|
@@ -300,9 +301,9 @@ class Session(BaseModel):
|
|
|
300
301
|
ui_extra=tr.ui_extra,
|
|
301
302
|
session_id=self.id,
|
|
302
303
|
status=tr.status,
|
|
303
|
-
|
|
304
|
+
task_metadata=tr.task_metadata,
|
|
304
305
|
)
|
|
305
|
-
|
|
306
|
+
yield from self._iter_sub_agent_history(tr, seen_sub_agent_sessions)
|
|
306
307
|
case model.UserMessageItem() as um:
|
|
307
308
|
yield events.UserMessageEvent(
|
|
308
309
|
content=um.content or "",
|
|
@@ -335,6 +336,35 @@ class Session(BaseModel):
|
|
|
335
336
|
case _:
|
|
336
337
|
continue
|
|
337
338
|
prev_item = it
|
|
339
|
+
yield events.TaskFinishEvent(session_id=self.id, task_result=last_assistant_content)
|
|
340
|
+
|
|
341
|
+
def _iter_sub_agent_history(
|
|
342
|
+
self, tool_result: model.ToolResultItem, seen_sub_agent_sessions: set[str]
|
|
343
|
+
) -> Iterable[events.HistoryItemEvent]:
|
|
344
|
+
"""Replay sub-agent session history when a tool result references it.
|
|
345
|
+
|
|
346
|
+
Sub-agent tool results embed a SessionIdUIExtra containing the child session ID.
|
|
347
|
+
When present, we load that session and yield its history events so replay/export
|
|
348
|
+
can show the full sub-agent transcript instead of only the summarized tool output.
|
|
349
|
+
"""
|
|
350
|
+
ui_extra = tool_result.ui_extra
|
|
351
|
+
if not isinstance(ui_extra, model.SessionIdUIExtra):
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
session_id = ui_extra.session_id
|
|
355
|
+
if not session_id or session_id == self.id:
|
|
356
|
+
return
|
|
357
|
+
if session_id in seen_sub_agent_sessions:
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
seen_sub_agent_sessions.add(session_id)
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
sub_session = Session.load(session_id)
|
|
364
|
+
except Exception:
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
yield from sub_session.get_history_item()
|
|
338
368
|
|
|
339
369
|
class SessionMetaBrief(BaseModel):
|
|
340
370
|
id: str
|
|
@@ -886,6 +886,52 @@
|
|
|
886
886
|
color: var(--text-dim);
|
|
887
887
|
}
|
|
888
888
|
|
|
889
|
+
/* Sub-agent Session */
|
|
890
|
+
details.sub-agent-session {
|
|
891
|
+
background: transparent;
|
|
892
|
+
margin: 16px 0;
|
|
893
|
+
border-left: 3px solid var(--accent);
|
|
894
|
+
padding-left: 16px;
|
|
895
|
+
}
|
|
896
|
+
details.sub-agent-session > summary {
|
|
897
|
+
padding: 4px 0;
|
|
898
|
+
font-family: var(--font-mono);
|
|
899
|
+
font-size: var(--font-size-sm);
|
|
900
|
+
text-transform: uppercase;
|
|
901
|
+
font-weight: var(--font-weight-bold);
|
|
902
|
+
cursor: pointer;
|
|
903
|
+
user-select: none;
|
|
904
|
+
list-style: none;
|
|
905
|
+
display: flex;
|
|
906
|
+
align-items: center;
|
|
907
|
+
gap: 8px;
|
|
908
|
+
min-height: 24px;
|
|
909
|
+
line-height: 1.2;
|
|
910
|
+
color: var(--text-dim);
|
|
911
|
+
transition: color 0.2s;
|
|
912
|
+
}
|
|
913
|
+
details.sub-agent-session > summary:hover,
|
|
914
|
+
details.sub-agent-session[open] > summary {
|
|
915
|
+
color: var(--text);
|
|
916
|
+
}
|
|
917
|
+
details.sub-agent-session > summary::-webkit-details-marker {
|
|
918
|
+
display: none;
|
|
919
|
+
}
|
|
920
|
+
details.sub-agent-session > summary::before {
|
|
921
|
+
content: "[+]";
|
|
922
|
+
color: var(--accent);
|
|
923
|
+
font-family: var(--font-mono);
|
|
924
|
+
margin-right: 4px;
|
|
925
|
+
display: inline-block;
|
|
926
|
+
min-width: 24px;
|
|
927
|
+
}
|
|
928
|
+
details.sub-agent-session[open] > summary::before {
|
|
929
|
+
content: "[-]";
|
|
930
|
+
}
|
|
931
|
+
details.sub-agent-session > .sub-agent-content {
|
|
932
|
+
padding: 8px 0 0 0;
|
|
933
|
+
}
|
|
934
|
+
|
|
889
935
|
/* Scroll to Bottom Button */
|
|
890
936
|
.scroll-btn {
|
|
891
937
|
position: fixed;
|
klaude_code/trace/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
from .log import DebugType, is_debug_enabled, log, log_debug, logger, set_debug_logging
|
|
1
|
+
from .log import DebugType, is_debug_enabled, log, log_debug, logger, prepare_debug_log_file, set_debug_logging
|
|
2
2
|
|
|
3
|
-
__all__ = ["
|
|
3
|
+
__all__ = ["DebugType", "is_debug_enabled", "log", "log_debug", "logger", "prepare_debug_log_file", "set_debug_logging"]
|
klaude_code/trace/log.py
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import gzip
|
|
1
2
|
import logging
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from datetime import datetime, timedelta
|
|
2
8
|
from enum import Enum
|
|
3
9
|
from logging.handlers import RotatingFileHandler
|
|
4
|
-
from
|
|
10
|
+
from pathlib import Path
|
|
5
11
|
|
|
6
12
|
from rich.console import Console
|
|
7
13
|
from rich.logging import RichHandler
|
|
@@ -49,6 +55,26 @@ _file_handler: RotatingFileHandler | None = None
|
|
|
49
55
|
_console_handler: RichHandler | None = None
|
|
50
56
|
_debug_filter: DebugTypeFilter | None = None
|
|
51
57
|
_debug_enabled = False
|
|
58
|
+
_current_log_file: Path | None = None
|
|
59
|
+
|
|
60
|
+
LOG_RETENTION_DAYS = 3
|
|
61
|
+
LOG_MAX_TOTAL_BYTES = 200 * 1024 * 1024
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GzipRotatingFileHandler(RotatingFileHandler):
|
|
65
|
+
"""Rotating file handler that gzips rolled files."""
|
|
66
|
+
|
|
67
|
+
def rotation_filename(self, default_name: str) -> str:
|
|
68
|
+
"""Append .gz to rotation targets."""
|
|
69
|
+
|
|
70
|
+
return f"{default_name}.gz"
|
|
71
|
+
|
|
72
|
+
def rotate(self, source: str, dest: str) -> None:
|
|
73
|
+
"""Compress the rotated file and remove the original."""
|
|
74
|
+
|
|
75
|
+
with open(source, "rb") as source_file, gzip.open(dest, "wb") as dest_file:
|
|
76
|
+
shutil.copyfileobj(source_file, dest_file)
|
|
77
|
+
Path(source).unlink(missing_ok=True)
|
|
52
78
|
|
|
53
79
|
|
|
54
80
|
def set_debug_logging(
|
|
@@ -66,7 +92,7 @@ def set_debug_logging(
|
|
|
66
92
|
log_file: Path to the log file (default: debug.log)
|
|
67
93
|
filters: Set of DebugType to include; None means all types
|
|
68
94
|
"""
|
|
69
|
-
global _file_handler, _console_handler, _debug_filter, _debug_enabled
|
|
95
|
+
global _file_handler, _console_handler, _debug_filter, _debug_enabled, _current_log_file
|
|
70
96
|
|
|
71
97
|
_debug_enabled = enabled
|
|
72
98
|
|
|
@@ -80,6 +106,7 @@ def set_debug_logging(
|
|
|
80
106
|
_console_handler = None
|
|
81
107
|
|
|
82
108
|
if not enabled:
|
|
109
|
+
_current_log_file = None
|
|
83
110
|
return
|
|
84
111
|
|
|
85
112
|
# Create filter
|
|
@@ -87,10 +114,19 @@ def set_debug_logging(
|
|
|
87
114
|
|
|
88
115
|
# Determine output mode
|
|
89
116
|
use_file = write_to_file if write_to_file is not None else True
|
|
90
|
-
file_path = log_file if log_file is not None else const.DEFAULT_DEBUG_LOG_FILE
|
|
91
|
-
|
|
92
117
|
if use_file:
|
|
93
|
-
|
|
118
|
+
if _current_log_file is None:
|
|
119
|
+
_current_log_file = _resolve_log_file(log_file)
|
|
120
|
+
file_path = _current_log_file
|
|
121
|
+
else:
|
|
122
|
+
_current_log_file = None
|
|
123
|
+
file_path = None
|
|
124
|
+
|
|
125
|
+
if use_file and file_path is not None:
|
|
126
|
+
_prune_old_logs(const.DEFAULT_DEBUG_LOG_DIR, LOG_RETENTION_DAYS, LOG_MAX_TOTAL_BYTES)
|
|
127
|
+
|
|
128
|
+
if use_file and file_path is not None:
|
|
129
|
+
_file_handler = GzipRotatingFileHandler(
|
|
94
130
|
file_path,
|
|
95
131
|
maxBytes=const.LOG_MAX_BYTES,
|
|
96
132
|
backupCount=const.LOG_BACKUP_COUNT,
|
|
@@ -166,3 +202,106 @@ def _build_message(objects: Iterable[str | tuple[str, str]]) -> str:
|
|
|
166
202
|
def is_debug_enabled() -> bool:
|
|
167
203
|
"""Check if debug logging is currently enabled."""
|
|
168
204
|
return _debug_enabled
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def prepare_debug_log_file(log_file: str | os.PathLike[str] | None = None) -> Path:
|
|
208
|
+
"""Prepare and remember the log file path for this session."""
|
|
209
|
+
|
|
210
|
+
global _current_log_file
|
|
211
|
+
_current_log_file = _resolve_log_file(log_file)
|
|
212
|
+
return _current_log_file
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def get_current_log_file() -> Path | None:
|
|
216
|
+
"""Return the currently active log file path, if any."""
|
|
217
|
+
|
|
218
|
+
return _current_log_file
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _resolve_log_file(log_file: str | os.PathLike[str] | None) -> Path:
|
|
222
|
+
"""Resolve the log file path and ensure directories exist."""
|
|
223
|
+
|
|
224
|
+
if log_file:
|
|
225
|
+
path = Path(log_file).expanduser()
|
|
226
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
return path
|
|
228
|
+
else:
|
|
229
|
+
path = _build_default_log_file_path()
|
|
230
|
+
|
|
231
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
path.touch(exist_ok=True)
|
|
233
|
+
_refresh_latest_symlink(path)
|
|
234
|
+
return path
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _build_default_log_file_path() -> Path:
|
|
238
|
+
"""Build a per-session log path under the default log directory."""
|
|
239
|
+
|
|
240
|
+
now = datetime.now()
|
|
241
|
+
session_dir = const.DEFAULT_DEBUG_LOG_DIR / now.strftime("%Y-%m-%d")
|
|
242
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
filename = f"{now.strftime('%H%M%S')}-{os.getpid()}.log"
|
|
244
|
+
return session_dir / filename
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _refresh_latest_symlink(target: Path) -> None:
|
|
248
|
+
"""Point the debug.log symlink at the latest session file."""
|
|
249
|
+
|
|
250
|
+
latest = const.DEFAULT_DEBUG_LOG_FILE
|
|
251
|
+
try:
|
|
252
|
+
latest.unlink(missing_ok=True)
|
|
253
|
+
latest.symlink_to(target)
|
|
254
|
+
except OSError:
|
|
255
|
+
# Non-blocking best-effort; logging should still proceed
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _prune_old_logs(log_root: Path, keep_days: int, max_total_bytes: int) -> None:
|
|
260
|
+
"""Remove logs older than keep_days or when exceeding max_total_bytes."""
|
|
261
|
+
|
|
262
|
+
if not log_root.exists():
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
cutoff = datetime.now() - timedelta(days=keep_days)
|
|
266
|
+
files: list[Path] = [p for p in log_root.rglob("*") if p.is_file() and not p.is_symlink()]
|
|
267
|
+
|
|
268
|
+
# Remove by age
|
|
269
|
+
for path in files:
|
|
270
|
+
try:
|
|
271
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime)
|
|
272
|
+
except OSError:
|
|
273
|
+
continue
|
|
274
|
+
if mtime < cutoff:
|
|
275
|
+
_trash_path(path)
|
|
276
|
+
|
|
277
|
+
# Recompute remaining files and sizes
|
|
278
|
+
remaining: list[tuple[Path, float, int]] = []
|
|
279
|
+
total_size = 0
|
|
280
|
+
for path in log_root.rglob("*"):
|
|
281
|
+
if not path.is_file() or path.is_symlink():
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
stat = path.stat()
|
|
285
|
+
except OSError:
|
|
286
|
+
continue
|
|
287
|
+
remaining.append((path, stat.st_mtime, stat.st_size))
|
|
288
|
+
total_size += stat.st_size
|
|
289
|
+
|
|
290
|
+
if total_size <= max_total_bytes:
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
remaining.sort(key=lambda item: item[1])
|
|
294
|
+
for path, _, size in remaining:
|
|
295
|
+
_trash_path(path)
|
|
296
|
+
total_size -= size
|
|
297
|
+
if total_size <= max_total_bytes:
|
|
298
|
+
break
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _trash_path(path: Path) -> None:
|
|
302
|
+
"""Send a path to trash, falling back to unlink if trash is unavailable."""
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
subprocess.run(["trash", str(path)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
306
|
+
except FileNotFoundError:
|
|
307
|
+
path.unlink(missing_ok=True)
|
klaude_code/ui/__init__.py
CHANGED
|
@@ -73,19 +73,14 @@ def create_exec_display(debug: bool = False, stream_json: bool = False) -> Displ
|
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
__all__ = [
|
|
76
|
-
|
|
76
|
+
"DebugEventDisplay",
|
|
77
77
|
"DisplayABC",
|
|
78
|
+
"ExecDisplay",
|
|
78
79
|
"InputProviderABC",
|
|
79
|
-
|
|
80
|
+
"PromptToolkitInput",
|
|
80
81
|
"REPLDisplay",
|
|
81
|
-
"ExecDisplay",
|
|
82
82
|
"StreamJsonDisplay",
|
|
83
|
-
"
|
|
84
|
-
# Input implementations
|
|
85
|
-
"PromptToolkitInput",
|
|
86
|
-
# Factory functions
|
|
83
|
+
"TerminalNotifier",
|
|
87
84
|
"create_default_display",
|
|
88
85
|
"create_exec_display",
|
|
89
|
-
# Supporting types
|
|
90
|
-
"TerminalNotifier",
|
|
91
86
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
3
4
|
from enum import Enum
|
|
4
|
-
from typing import Awaitable, Callable
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class Stage(Enum):
|
|
@@ -19,10 +19,12 @@ class StageManager:
|
|
|
19
19
|
self,
|
|
20
20
|
*,
|
|
21
21
|
finish_assistant: Callable[[], Awaitable[None]],
|
|
22
|
+
finish_thinking: Callable[[], Awaitable[None]],
|
|
22
23
|
on_enter_thinking: Callable[[], None],
|
|
23
24
|
):
|
|
24
25
|
self._stage = Stage.WAITING
|
|
25
26
|
self._finish_assistant = finish_assistant
|
|
27
|
+
self._finish_thinking = finish_thinking
|
|
26
28
|
self._on_enter_thinking = on_enter_thinking
|
|
27
29
|
|
|
28
30
|
@property
|
|
@@ -49,7 +51,8 @@ class StageManager:
|
|
|
49
51
|
self._stage = Stage.WAITING
|
|
50
52
|
|
|
51
53
|
async def _leave_current_stage(self) -> None:
|
|
52
|
-
if self._stage == Stage.
|
|
54
|
+
if self._stage == Stage.THINKING:
|
|
55
|
+
await self._finish_thinking()
|
|
56
|
+
elif self._stage == Stage.ASSISTANT:
|
|
53
57
|
await self.finish_assistant()
|
|
54
|
-
|
|
55
|
-
self._stage = Stage.WAITING
|
|
58
|
+
self._stage = Stage.WAITING
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import os
|
|
1
2
|
from typing import override
|
|
2
3
|
|
|
3
4
|
from klaude_code import const
|
|
@@ -10,7 +11,7 @@ class DebugEventDisplay(DisplayABC):
|
|
|
10
11
|
def __init__(
|
|
11
12
|
self,
|
|
12
13
|
wrapped_display: DisplayABC | None = None,
|
|
13
|
-
log_file: str = const.DEFAULT_DEBUG_LOG_FILE,
|
|
14
|
+
log_file: str | os.PathLike[str] = const.DEFAULT_DEBUG_LOG_FILE,
|
|
14
15
|
):
|
|
15
16
|
self.wrapped_display = wrapped_display
|
|
16
17
|
self.log_file = log_file
|
|
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
|
|
|
9
9
|
from klaude_code.core.agent import Agent
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def build_repl_status_snapshot(agent:
|
|
12
|
+
def build_repl_status_snapshot(agent: Agent | None, update_message: str | None) -> REPLStatusSnapshot:
|
|
13
13
|
"""Build a status snapshot for the REPL bottom toolbar.
|
|
14
14
|
|
|
15
15
|
Aggregates model name, context usage, and basic call counts from the
|
|
@@ -67,12 +67,12 @@ class _SlashCommandCompleter(Completer):
|
|
|
67
67
|
) -> Iterable[Completion]:
|
|
68
68
|
# Only complete on first line
|
|
69
69
|
if document.cursor_position_row != 0:
|
|
70
|
-
return
|
|
70
|
+
return
|
|
71
71
|
|
|
72
72
|
text_before = document.current_line_before_cursor
|
|
73
73
|
m = self._SLASH_TOKEN_RE.search(text_before)
|
|
74
74
|
if not m:
|
|
75
|
-
return
|
|
75
|
+
return
|
|
76
76
|
|
|
77
77
|
frag = m.group("frag")
|
|
78
78
|
token_start = len(text_before) - len(f"/{frag}")
|
|
@@ -89,7 +89,7 @@ class _SlashCommandCompleter(Completer):
|
|
|
89
89
|
matched.append((cmd_name, cmd_obj, hint))
|
|
90
90
|
|
|
91
91
|
if not matched:
|
|
92
|
-
return
|
|
92
|
+
return
|
|
93
93
|
|
|
94
94
|
# Calculate max width for alignment
|
|
95
95
|
# Find the longest command+hint length
|
|
@@ -133,10 +133,9 @@ class _ComboCompleter(Completer):
|
|
|
133
133
|
complete_event, # type: ignore[override]
|
|
134
134
|
) -> Iterable[Completion]:
|
|
135
135
|
# Try slash command completion first (only on first line)
|
|
136
|
-
if document.cursor_position_row == 0:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return
|
|
136
|
+
if document.cursor_position_row == 0 and self._slash_completer.is_slash_command_context(document):
|
|
137
|
+
yield from self._slash_completer.get_completions(document, complete_event)
|
|
138
|
+
return
|
|
140
139
|
|
|
141
140
|
# Fall back to @ file completion
|
|
142
141
|
yield from self._at_completer.get_completions(document, complete_event)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
from typing import override
|
|
4
5
|
|
|
5
6
|
from klaude_code.protocol import events
|
|
@@ -53,8 +54,6 @@ class REPLDisplay(DisplayABC):
|
|
|
53
54
|
async def stop(self) -> None:
|
|
54
55
|
await self.event_handler.stop()
|
|
55
56
|
# Ensure any active spinner is stopped so Rich restores the cursor.
|
|
56
|
-
|
|
57
|
+
# Spinner may already be stopped or not started; ignore.
|
|
58
|
+
with contextlib.suppress(Exception):
|
|
57
59
|
self.renderer.spinner_stop()
|
|
58
|
-
except Exception:
|
|
59
|
-
# Spinner may already be stopped or not started; ignore.
|
|
60
|
-
pass
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
3
4
|
from dataclasses import dataclass
|
|
4
|
-
from typing import Awaitable, Callable
|
|
5
5
|
|
|
6
6
|
from rich.text import Text
|
|
7
7
|
|
|
@@ -10,6 +10,7 @@ from klaude_code.protocol import events
|
|
|
10
10
|
from klaude_code.ui.core.stage_manager import Stage, StageManager
|
|
11
11
|
from klaude_code.ui.modes.repl.renderer import REPLRenderer
|
|
12
12
|
from klaude_code.ui.rich.markdown import MarkdownStream
|
|
13
|
+
from klaude_code.ui.rich.theme import ThemeKey
|
|
13
14
|
from klaude_code.ui.terminal.notifier import Notification, NotificationType, TerminalNotifier
|
|
14
15
|
from klaude_code.ui.terminal.progress_bar import OSC94States, emit_osc94
|
|
15
16
|
from klaude_code.ui.utils.debouncer import Debouncer
|
|
@@ -41,7 +42,7 @@ class StreamState:
|
|
|
41
42
|
This design ensures buffer and mdstream are always in sync.
|
|
42
43
|
"""
|
|
43
44
|
|
|
44
|
-
def __init__(self, interval: float, flush_handler: Callable[[
|
|
45
|
+
def __init__(self, interval: float, flush_handler: Callable[[StreamState], Awaitable[None]]):
|
|
45
46
|
self._active: ActiveStream | None = None
|
|
46
47
|
self._flush_handler = flush_handler
|
|
47
48
|
self.debouncer = Debouncer(interval=interval, callback=self._debounced_flush)
|
|
@@ -199,10 +200,14 @@ class DisplayEventHandler:
|
|
|
199
200
|
self.assistant_stream = StreamState(
|
|
200
201
|
interval=1 / const.UI_REFRESH_RATE_FPS, flush_handler=self._flush_assistant_buffer
|
|
201
202
|
)
|
|
203
|
+
self.thinking_stream = StreamState(
|
|
204
|
+
interval=1 / const.UI_REFRESH_RATE_FPS, flush_handler=self._flush_thinking_buffer
|
|
205
|
+
)
|
|
202
206
|
self.spinner_status = SpinnerStatusState()
|
|
203
207
|
|
|
204
208
|
self.stage_manager = StageManager(
|
|
205
209
|
finish_assistant=self._finish_assistant_stream,
|
|
210
|
+
finish_thinking=self._finish_thinking_stream,
|
|
206
211
|
on_enter_thinking=self._print_thinking_prefix,
|
|
207
212
|
)
|
|
208
213
|
|
|
@@ -222,6 +227,8 @@ class DisplayEventHandler:
|
|
|
222
227
|
self._on_turn_start(e)
|
|
223
228
|
case events.ThinkingEvent() as e:
|
|
224
229
|
await self._on_thinking(e)
|
|
230
|
+
case events.ThinkingDeltaEvent() as e:
|
|
231
|
+
await self._on_thinking_delta(e)
|
|
225
232
|
case events.AssistantMessageDeltaEvent() as e:
|
|
226
233
|
await self._on_assistant_delta(e)
|
|
227
234
|
case events.AssistantMessageEvent() as e:
|
|
@@ -252,6 +259,8 @@ class DisplayEventHandler:
|
|
|
252
259
|
async def stop(self) -> None:
|
|
253
260
|
await self.assistant_stream.debouncer.flush()
|
|
254
261
|
self.assistant_stream.debouncer.cancel()
|
|
262
|
+
await self.thinking_stream.debouncer.flush()
|
|
263
|
+
self.thinking_stream.debouncer.cancel()
|
|
255
264
|
|
|
256
265
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
257
266
|
# Private event handlers
|
|
@@ -285,8 +294,41 @@ class DisplayEventHandler:
|
|
|
285
294
|
async def _on_thinking(self, event: events.ThinkingEvent) -> None:
|
|
286
295
|
if self.renderer.is_sub_agent_session(event.session_id):
|
|
287
296
|
return
|
|
297
|
+
# If streaming was active, finalize it
|
|
298
|
+
if self.thinking_stream.is_active:
|
|
299
|
+
await self._finish_thinking_stream()
|
|
300
|
+
else:
|
|
301
|
+
# Non-streaming path (history replay or models without delta support)
|
|
302
|
+
await self.stage_manager.enter_thinking_stage()
|
|
303
|
+
self.renderer.display_thinking(event.content)
|
|
304
|
+
|
|
305
|
+
async def _on_thinking_delta(self, event: events.ThinkingDeltaEvent) -> None:
|
|
306
|
+
if self.renderer.is_sub_agent_session(event.session_id):
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
first_delta = not self.thinking_stream.is_active
|
|
310
|
+
if first_delta:
|
|
311
|
+
self.renderer.console.push_theme(self.renderer.themes.thinking_markdown_theme)
|
|
312
|
+
mdstream = MarkdownStream(
|
|
313
|
+
mdargs={
|
|
314
|
+
"code_theme": self.renderer.themes.code_theme,
|
|
315
|
+
"style": self.renderer.console.get_style(ThemeKey.THINKING),
|
|
316
|
+
},
|
|
317
|
+
theme=self.renderer.themes.thinking_markdown_theme,
|
|
318
|
+
console=self.renderer.console,
|
|
319
|
+
spinner=self.renderer.spinner_renderable(),
|
|
320
|
+
indent=2,
|
|
321
|
+
)
|
|
322
|
+
self.thinking_stream.start(mdstream)
|
|
323
|
+
self.renderer.spinner_stop()
|
|
324
|
+
|
|
325
|
+
self.thinking_stream.append(event.content)
|
|
326
|
+
|
|
327
|
+
if first_delta and self.thinking_stream.mdstream is not None:
|
|
328
|
+
self.thinking_stream.mdstream.update(self.thinking_stream.buffer)
|
|
329
|
+
|
|
288
330
|
await self.stage_manager.enter_thinking_stage()
|
|
289
|
-
self.
|
|
331
|
+
self.thinking_stream.debouncer.schedule()
|
|
290
332
|
|
|
291
333
|
async def _on_assistant_delta(self, event: events.AssistantMessageDeltaEvent) -> None:
|
|
292
334
|
if self.renderer.is_sub_agent_session(event.session_id):
|
|
@@ -419,6 +461,22 @@ class DisplayEventHandler:
|
|
|
419
461
|
assert mdstream is not None
|
|
420
462
|
mdstream.update(state.buffer)
|
|
421
463
|
|
|
464
|
+
async def _flush_thinking_buffer(self, state: StreamState) -> None:
|
|
465
|
+
if state.is_active:
|
|
466
|
+
mdstream = state.mdstream
|
|
467
|
+
assert mdstream is not None
|
|
468
|
+
mdstream.update(state.buffer)
|
|
469
|
+
|
|
470
|
+
async def _finish_thinking_stream(self) -> None:
|
|
471
|
+
if self.thinking_stream.is_active:
|
|
472
|
+
self.thinking_stream.debouncer.cancel()
|
|
473
|
+
mdstream = self.thinking_stream.mdstream
|
|
474
|
+
assert mdstream is not None
|
|
475
|
+
mdstream.update(self.thinking_stream.buffer, final=True)
|
|
476
|
+
self.thinking_stream.finish()
|
|
477
|
+
self.renderer.console.pop_theme()
|
|
478
|
+
self.renderer.spinner_start()
|
|
479
|
+
|
|
422
480
|
def _maybe_notify_task_finish(self, event: events.TaskFinishEvent) -> None:
|
|
423
481
|
if self.notifier is None:
|
|
424
482
|
return
|
|
@@ -453,10 +511,10 @@ class DisplayEventHandler:
|
|
|
453
511
|
if len(todo.content) > 0:
|
|
454
512
|
status_text = todo.content
|
|
455
513
|
status_text = status_text.replace("\n", "")
|
|
456
|
-
return self._truncate_status_text(status_text, max_length=
|
|
514
|
+
return self._truncate_status_text(status_text, max_length=50)
|
|
457
515
|
|
|
458
516
|
def _truncate_status_text(self, text: str, max_length: int) -> str:
|
|
459
517
|
if len(text) <= max_length:
|
|
460
518
|
return text
|
|
461
519
|
truncated = text[:max_length]
|
|
462
|
-
return truncated + "
|
|
520
|
+
return truncated + "…"
|
|
@@ -6,6 +6,7 @@ with dependencies injected to avoid circular imports.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import contextlib
|
|
9
10
|
import re
|
|
10
11
|
from collections.abc import Callable
|
|
11
12
|
from typing import cast
|
|
@@ -35,10 +36,8 @@ def create_key_bindings(
|
|
|
35
36
|
"""Paste image from clipboard as [Image #N]."""
|
|
36
37
|
tag = capture_clipboard_tag()
|
|
37
38
|
if tag:
|
|
38
|
-
|
|
39
|
+
with contextlib.suppress(Exception):
|
|
39
40
|
event.current_buffer.insert_text(tag) # pyright: ignore[reportUnknownMemberType]
|
|
40
|
-
except Exception:
|
|
41
|
-
pass
|
|
42
41
|
|
|
43
42
|
@kb.add("enter")
|
|
44
43
|
def _(event): # type: ignore
|