klaude-code 1.2.17__py3-none-any.whl → 1.2.19__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/cli/config_cmd.py +1 -1
- klaude_code/cli/debug.py +1 -1
- klaude_code/cli/main.py +45 -31
- klaude_code/cli/runtime.py +49 -13
- klaude_code/{version.py → cli/self_update.py} +110 -2
- klaude_code/command/__init__.py +4 -1
- klaude_code/command/clear_cmd.py +2 -7
- klaude_code/command/command_abc.py +33 -5
- klaude_code/command/debug_cmd.py +79 -0
- klaude_code/command/diff_cmd.py +2 -6
- klaude_code/command/export_cmd.py +7 -7
- klaude_code/command/export_online_cmd.py +9 -8
- klaude_code/command/help_cmd.py +4 -9
- klaude_code/command/model_cmd.py +10 -6
- klaude_code/command/prompt_command.py +2 -6
- klaude_code/command/refresh_cmd.py +2 -7
- klaude_code/command/registry.py +69 -26
- klaude_code/command/release_notes_cmd.py +2 -6
- klaude_code/command/status_cmd.py +2 -7
- klaude_code/command/terminal_setup_cmd.py +2 -6
- klaude_code/command/thinking_cmd.py +16 -10
- klaude_code/config/select_model.py +81 -5
- klaude_code/const/__init__.py +1 -1
- klaude_code/core/executor.py +257 -110
- klaude_code/core/manager/__init__.py +2 -4
- klaude_code/core/prompts/prompt-claude-code.md +1 -1
- klaude_code/core/prompts/prompt-sub-agent-explore.md +14 -2
- klaude_code/core/prompts/prompt-sub-agent-web.md +8 -5
- klaude_code/core/reminders.py +9 -35
- klaude_code/core/task.py +9 -7
- klaude_code/core/tool/file/read_tool.md +1 -1
- klaude_code/core/tool/file/read_tool.py +41 -12
- klaude_code/core/tool/memory/skill_loader.py +12 -10
- klaude_code/core/tool/shell/bash_tool.py +22 -2
- klaude_code/core/tool/tool_registry.py +1 -1
- klaude_code/core/tool/tool_runner.py +26 -23
- klaude_code/core/tool/truncation.py +23 -9
- klaude_code/core/tool/web/web_fetch_tool.md +1 -1
- klaude_code/core/tool/web/web_fetch_tool.py +36 -1
- klaude_code/core/turn.py +28 -0
- klaude_code/llm/anthropic/client.py +25 -9
- klaude_code/llm/openai_compatible/client.py +5 -2
- klaude_code/llm/openrouter/client.py +7 -3
- klaude_code/llm/responses/client.py +6 -1
- klaude_code/protocol/commands.py +1 -0
- klaude_code/protocol/sub_agent/web.py +3 -2
- klaude_code/session/session.py +35 -15
- klaude_code/session/templates/export_session.html +45 -32
- klaude_code/trace/__init__.py +20 -2
- klaude_code/ui/modes/repl/completers.py +231 -73
- klaude_code/ui/modes/repl/event_handler.py +8 -6
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
- klaude_code/ui/modes/repl/renderer.py +2 -2
- klaude_code/ui/renderers/common.py +54 -0
- klaude_code/ui/renderers/developer.py +2 -3
- klaude_code/ui/renderers/errors.py +1 -1
- klaude_code/ui/renderers/metadata.py +12 -5
- klaude_code/ui/renderers/thinking.py +24 -8
- klaude_code/ui/renderers/tools.py +82 -14
- klaude_code/ui/rich/code_panel.py +112 -0
- klaude_code/ui/rich/markdown.py +3 -4
- klaude_code/ui/rich/status.py +0 -2
- klaude_code/ui/rich/theme.py +10 -1
- klaude_code/ui/utils/common.py +0 -18
- {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/METADATA +32 -7
- {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/RECORD +69 -68
- klaude_code/core/manager/agent_manager.py +0 -132
- /klaude_code/{config → cli}/list_model.py +0 -0
- {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/entry_points.txt +0 -0
|
@@ -26,6 +26,7 @@ from prompt_toolkit.document import Document
|
|
|
26
26
|
from prompt_toolkit.formatted_text import HTML
|
|
27
27
|
|
|
28
28
|
from klaude_code.command import get_commands
|
|
29
|
+
from klaude_code.trace.log import DebugType, log_debug
|
|
29
30
|
|
|
30
31
|
# Pattern to match @token for completion refresh (used by key bindings).
|
|
31
32
|
# Supports both plain tokens like `@src/file.py` and quoted tokens like
|
|
@@ -85,7 +86,7 @@ class _SlashCommandCompleter(Completer):
|
|
|
85
86
|
matched: list[tuple[str, object, str]] = []
|
|
86
87
|
for cmd_name, cmd_obj in commands.items():
|
|
87
88
|
if cmd_name.startswith(frag):
|
|
88
|
-
hint = " [
|
|
89
|
+
hint = f" [{cmd_obj.placeholder}]" if cmd_obj.support_addition_params else ""
|
|
89
90
|
matched.append((cmd_name, cmd_obj, hint))
|
|
90
91
|
|
|
91
92
|
if not matched:
|
|
@@ -157,7 +158,7 @@ class _AtFilesCompleter(Completer):
|
|
|
157
158
|
def __init__(
|
|
158
159
|
self,
|
|
159
160
|
debounce_sec: float = 0.25,
|
|
160
|
-
cache_ttl_sec: float =
|
|
161
|
+
cache_ttl_sec: float = 60.0,
|
|
161
162
|
max_results: int = 20,
|
|
162
163
|
):
|
|
163
164
|
self._debounce_sec = debounce_sec
|
|
@@ -169,13 +170,26 @@ class _AtFilesCompleter(Completer):
|
|
|
169
170
|
self._last_query_key: str | None = None
|
|
170
171
|
self._last_results: list[str] = []
|
|
171
172
|
self._last_results_time: float = 0.0
|
|
173
|
+
self._last_results_truncated: bool = False
|
|
172
174
|
|
|
173
175
|
# rg --files cache (used when fd is unavailable)
|
|
174
176
|
self._rg_file_list: list[str] | None = None
|
|
175
177
|
self._rg_file_list_time: float = 0.0
|
|
178
|
+
self._rg_file_list_cwd: Path | None = None
|
|
176
179
|
|
|
177
|
-
#
|
|
178
|
-
self.
|
|
180
|
+
# git ls-files cache (preferred when inside a git repo)
|
|
181
|
+
self._git_repo_root: Path | None = None
|
|
182
|
+
self._git_repo_root_time: float = 0.0
|
|
183
|
+
self._git_repo_root_cwd: Path | None = None
|
|
184
|
+
|
|
185
|
+
self._git_file_list: list[str] | None = None
|
|
186
|
+
self._git_file_list_lower: list[str] | None = None
|
|
187
|
+
self._git_file_list_time: float = 0.0
|
|
188
|
+
self._git_file_list_cwd: Path | None = None
|
|
189
|
+
|
|
190
|
+
# Command timeout is intentionally higher than a keypress cadence.
|
|
191
|
+
# We rely on caching/narrowing to avoid calling fd repeatedly.
|
|
192
|
+
self._cmd_timeout_sec: float = 3.0
|
|
179
193
|
|
|
180
194
|
# ---- prompt_toolkit API ----
|
|
181
195
|
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: # type: ignore[override]
|
|
@@ -233,6 +247,8 @@ class _AtFilesCompleter(Completer):
|
|
|
233
247
|
key_norm = keyword.lower()
|
|
234
248
|
query_key = f"{cwd.resolve()}::search::{key_norm}"
|
|
235
249
|
|
|
250
|
+
max_scan_results = self._max_results * 3
|
|
251
|
+
|
|
236
252
|
# Debounce: if called too soon again, filter last results
|
|
237
253
|
if self._last_results and self._last_query_key is not None:
|
|
238
254
|
prev = self._last_query_key
|
|
@@ -246,85 +262,121 @@ class _AtFilesCompleter(Completer):
|
|
|
246
262
|
and len(cur_kw) >= len(prev_kw)
|
|
247
263
|
and cur_kw.startswith(prev_kw)
|
|
248
264
|
)
|
|
265
|
+
|
|
266
|
+
# If the previous result set was not truncated, it is a complete
|
|
267
|
+
# superset for any narrower prefix. Reuse it even if the user
|
|
268
|
+
# pauses between keystrokes.
|
|
269
|
+
if (
|
|
270
|
+
is_narrowing
|
|
271
|
+
and not self._last_results_truncated
|
|
272
|
+
and now - self._last_results_time < self._cache_ttl
|
|
273
|
+
):
|
|
274
|
+
return self._filter_and_format(self._last_results, cwd, key_norm)
|
|
275
|
+
|
|
249
276
|
if is_narrowing and (now - self._last_cmd_time) < self._debounce_sec:
|
|
250
|
-
# For narrowing, fast-filter previous results to avoid expensive calls
|
|
251
|
-
|
|
277
|
+
# For rapid narrowing, fast-filter previous results to avoid expensive calls
|
|
278
|
+
# If the previous result set was truncated (e.g., for a 1-char query),
|
|
279
|
+
# filtering it can legitimately produce an empty set even when matches
|
|
280
|
+
# exist elsewhere. Fall back to a real search in that case.
|
|
281
|
+
filtered = self._filter_and_format(self._last_results, cwd, key_norm)
|
|
282
|
+
if filtered:
|
|
283
|
+
return filtered
|
|
252
284
|
|
|
253
285
|
# Cache TTL: reuse cached results for same query within TTL
|
|
254
286
|
if self._last_results and self._last_query_key == query_key and now - self._last_results_time < self._cache_ttl:
|
|
255
|
-
return self._filter_and_format(self._last_results, cwd, key_norm
|
|
287
|
+
return self._filter_and_format(self._last_results, cwd, key_norm)
|
|
256
288
|
|
|
257
|
-
# Prefer
|
|
289
|
+
# Prefer git index (fast in large repos), then fd, then rg --files.
|
|
258
290
|
results: list[str] = []
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
291
|
+
truncated = False
|
|
292
|
+
|
|
293
|
+
# For very short keywords, prefer fd's early-exit behavior.
|
|
294
|
+
# For keywords >= 2 chars, using git's file list is typically faster
|
|
295
|
+
# than scanning the filesystem repeatedly.
|
|
296
|
+
if len(key_norm) >= 2:
|
|
297
|
+
results, truncated = self._git_paths_for_keyword(cwd, key_norm, max_results=max_scan_results)
|
|
298
|
+
|
|
299
|
+
if not results:
|
|
300
|
+
if self._has_cmd("fd"):
|
|
301
|
+
# Use fd to search anywhere in full path (files and directories), case-insensitive
|
|
302
|
+
results, truncated = self._run_fd_search(cwd, key_norm, max_results=max_scan_results)
|
|
303
|
+
elif self._has_cmd("rg"):
|
|
304
|
+
# Use rg to search only in current directory
|
|
305
|
+
rg_cache_ttl = max(self._cache_ttl, 30.0)
|
|
306
|
+
if (
|
|
307
|
+
self._rg_file_list is None
|
|
308
|
+
or self._rg_file_list_cwd != cwd
|
|
309
|
+
or now - self._rg_file_list_time > rg_cache_ttl
|
|
310
|
+
):
|
|
311
|
+
cmd = [
|
|
312
|
+
"rg",
|
|
313
|
+
"--files",
|
|
314
|
+
"--no-ignore",
|
|
315
|
+
"--hidden",
|
|
316
|
+
"--glob",
|
|
317
|
+
"!**/.git/**",
|
|
318
|
+
"--glob",
|
|
319
|
+
"!**/.venv/**",
|
|
320
|
+
"--glob",
|
|
321
|
+
"!**/node_modules/**",
|
|
322
|
+
]
|
|
323
|
+
r = self._run_cmd(cmd, cwd=cwd, timeout_sec=self._cmd_timeout_sec) # Search from current directory
|
|
324
|
+
if r.ok:
|
|
325
|
+
self._rg_file_list = r.lines
|
|
326
|
+
self._rg_file_list_time = now
|
|
327
|
+
self._rg_file_list_cwd = cwd
|
|
328
|
+
else:
|
|
329
|
+
self._rg_file_list = []
|
|
330
|
+
self._rg_file_list_time = now
|
|
331
|
+
self._rg_file_list_cwd = cwd
|
|
332
|
+
# Filter by keyword
|
|
333
|
+
all_files = self._rg_file_list or []
|
|
334
|
+
kn = key_norm
|
|
335
|
+
results = [p for p in all_files if kn in p.lower()]
|
|
336
|
+
# For rg fallback, we don't distinguish ignored files (no priority sorting)
|
|
337
|
+
else:
|
|
338
|
+
return []
|
|
281
339
|
|
|
282
340
|
# Update caches
|
|
283
341
|
self._last_cmd_time = now
|
|
284
342
|
self._last_query_key = query_key
|
|
285
343
|
self._last_results = results
|
|
286
344
|
self._last_results_time = now
|
|
287
|
-
self.
|
|
288
|
-
return self._filter_and_format(results, cwd, key_norm
|
|
345
|
+
self._last_results_truncated = truncated
|
|
346
|
+
return self._filter_and_format(results, cwd, key_norm)
|
|
289
347
|
|
|
290
348
|
def _filter_and_format(
|
|
291
349
|
self,
|
|
292
350
|
paths_from_root: list[str],
|
|
293
351
|
cwd: Path,
|
|
294
352
|
keyword_norm: str,
|
|
295
|
-
ignored_paths: set[str] | None = None,
|
|
296
353
|
) -> list[str]:
|
|
297
354
|
# Filter to keyword (case-insensitive) and rank by:
|
|
298
|
-
# 1.
|
|
299
|
-
# 2. Basename hit first, then path hit position, then length
|
|
355
|
+
# 1. Basename hit first, then path hit position, then length
|
|
300
356
|
# Since both fd and rg now search from current directory, all paths are relative to cwd
|
|
301
357
|
kn = keyword_norm
|
|
302
|
-
|
|
303
|
-
out: list[tuple[str, tuple[int, int, int, int, int]]] = []
|
|
358
|
+
out: list[tuple[str, tuple[int, int, int, int]]] = []
|
|
304
359
|
for p in paths_from_root:
|
|
305
360
|
pl = p.lower()
|
|
306
361
|
if kn not in pl:
|
|
307
362
|
continue
|
|
308
363
|
|
|
309
|
-
#
|
|
310
|
-
|
|
311
|
-
|
|
364
|
+
# Most tools return paths relative to cwd. Some include a leading
|
|
365
|
+
# './' prefix; strip that exact prefix only.
|
|
366
|
+
#
|
|
367
|
+
# Do not use lstrip('./') here: it would also remove the leading '.'
|
|
368
|
+
# from dotfiles/directories like '.claude/'.
|
|
369
|
+
rel_to_cwd = p.removeprefix("./").removeprefix(".\\")
|
|
370
|
+
base = os.path.basename(rel_to_cwd.rstrip("/")).lower()
|
|
312
371
|
base_pos = base.find(kn)
|
|
313
372
|
path_pos = pl.find(kn)
|
|
314
|
-
# Check if this path is in the ignored set (gitignored files)
|
|
315
|
-
is_ignored = 1 if rel_to_cwd in ignored_paths else 0
|
|
316
373
|
score = (
|
|
317
|
-
is_ignored,
|
|
318
374
|
0 if base_pos != -1 else 1,
|
|
319
375
|
base_pos if base_pos != -1 else 10_000,
|
|
320
376
|
path_pos,
|
|
321
377
|
len(p),
|
|
322
378
|
)
|
|
323
379
|
|
|
324
|
-
# Append trailing slash for directories
|
|
325
|
-
full_path = cwd / rel_to_cwd
|
|
326
|
-
if full_path.is_dir() and not rel_to_cwd.endswith("/"):
|
|
327
|
-
rel_to_cwd = rel_to_cwd + "/"
|
|
328
380
|
out.append((rel_to_cwd, score))
|
|
329
381
|
# Sort by score
|
|
330
382
|
out.sort(key=lambda x: x[1])
|
|
@@ -335,6 +387,19 @@ class _AtFilesCompleter(Completer):
|
|
|
335
387
|
if s not in seen:
|
|
336
388
|
seen.add(s)
|
|
337
389
|
uniq.append(s)
|
|
390
|
+
|
|
391
|
+
# Append trailing slash for directories, but avoid excessive stats.
|
|
392
|
+
# For large candidate lists, only stat the most relevant prefixes.
|
|
393
|
+
stat_limit = min(len(uniq), max(self._max_results * 3, 60))
|
|
394
|
+
for idx in range(stat_limit):
|
|
395
|
+
s = uniq[idx]
|
|
396
|
+
if s.endswith("/"):
|
|
397
|
+
continue
|
|
398
|
+
try:
|
|
399
|
+
if (cwd / s).is_dir():
|
|
400
|
+
uniq[idx] = f"{s}/"
|
|
401
|
+
except Exception:
|
|
402
|
+
continue
|
|
338
403
|
return uniq
|
|
339
404
|
|
|
340
405
|
def _format_completion_text(self, suggestion: str, *, is_quoted: bool) -> str:
|
|
@@ -370,15 +435,13 @@ class _AtFilesCompleter(Completer):
|
|
|
370
435
|
return None, None
|
|
371
436
|
|
|
372
437
|
# ---- Utilities ----
|
|
373
|
-
def _run_fd_search(self, cwd: Path, keyword_norm: str) -> tuple[list[str],
|
|
374
|
-
"""Run fd search and return (
|
|
438
|
+
def _run_fd_search(self, cwd: Path, keyword_norm: str, *, max_results: int) -> tuple[list[str], bool]:
|
|
439
|
+
"""Run fd search and return (results, truncated).
|
|
375
440
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
Returns the combined results and a set of paths that are gitignored.
|
|
441
|
+
Note: This is called in the prompt_toolkit completion path, so avoid
|
|
442
|
+
doing extra background scans here.
|
|
379
443
|
"""
|
|
380
|
-
|
|
381
|
-
base_cmd = [
|
|
444
|
+
cmd = [
|
|
382
445
|
"fd",
|
|
383
446
|
"--color=never",
|
|
384
447
|
"--type",
|
|
@@ -388,36 +451,115 @@ class _AtFilesCompleter(Completer):
|
|
|
388
451
|
"--hidden",
|
|
389
452
|
"--full-path",
|
|
390
453
|
"-i",
|
|
454
|
+
"-F",
|
|
391
455
|
"--max-results",
|
|
392
|
-
str(
|
|
456
|
+
str(max_results),
|
|
393
457
|
"--exclude",
|
|
394
458
|
".git",
|
|
395
459
|
"--exclude",
|
|
396
460
|
".venv",
|
|
397
461
|
"--exclude",
|
|
398
462
|
"node_modules",
|
|
399
|
-
|
|
463
|
+
keyword_norm,
|
|
400
464
|
".",
|
|
401
465
|
]
|
|
402
466
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
467
|
+
r = self._run_cmd(cmd, cwd=cwd, timeout_sec=self._cmd_timeout_sec)
|
|
468
|
+
lines = r.lines if r.ok else []
|
|
469
|
+
return lines, (len(lines) >= max_results)
|
|
470
|
+
|
|
471
|
+
def _git_paths_for_keyword(self, cwd: Path, keyword_norm: str, *, max_results: int) -> tuple[list[str], bool]:
|
|
472
|
+
"""Get path suggestions from the git index (fast for large repos).
|
|
406
473
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
474
|
+
Returns (candidates, truncated). "truncated" is True when we
|
|
475
|
+
intentionally stop early to keep per-keystroke costs bounded.
|
|
476
|
+
"""
|
|
477
|
+
repo_root = self._get_git_repo_root(cwd)
|
|
478
|
+
if repo_root is None:
|
|
479
|
+
return [], False
|
|
480
|
+
|
|
481
|
+
now = time.monotonic()
|
|
482
|
+
git_cache_ttl = max(self._cache_ttl, 30.0)
|
|
483
|
+
if (
|
|
484
|
+
self._git_file_list is None
|
|
485
|
+
or self._git_file_list_cwd != cwd
|
|
486
|
+
or now - self._git_file_list_time > git_cache_ttl
|
|
487
|
+
):
|
|
488
|
+
cmd = ["git", "ls-files", "-co", "--exclude-standard"]
|
|
489
|
+
r = self._run_cmd(cmd, cwd=repo_root, timeout_sec=self._cmd_timeout_sec)
|
|
490
|
+
if not r.ok:
|
|
491
|
+
self._git_file_list = []
|
|
492
|
+
self._git_file_list_lower = []
|
|
493
|
+
self._git_file_list_time = now
|
|
494
|
+
self._git_file_list_cwd = cwd
|
|
495
|
+
else:
|
|
496
|
+
cwd_resolved = cwd.resolve()
|
|
497
|
+
root_resolved = repo_root.resolve()
|
|
498
|
+
files: list[str] = []
|
|
499
|
+
files_lower: list[str] = []
|
|
500
|
+
for rel in r.lines:
|
|
501
|
+
abs_path = root_resolved / rel
|
|
502
|
+
try:
|
|
503
|
+
rel_to_cwd = abs_path.relative_to(cwd_resolved)
|
|
504
|
+
except ValueError:
|
|
505
|
+
continue
|
|
506
|
+
rel_posix = rel_to_cwd.as_posix()
|
|
507
|
+
files.append(rel_posix)
|
|
508
|
+
files_lower.append(rel_posix.lower())
|
|
509
|
+
self._git_file_list = files
|
|
510
|
+
self._git_file_list_lower = files_lower
|
|
511
|
+
self._git_file_list_time = now
|
|
512
|
+
self._git_file_list_cwd = cwd
|
|
513
|
+
|
|
514
|
+
all_files = self._git_file_list or []
|
|
515
|
+
all_files_lower = self._git_file_list_lower or []
|
|
516
|
+
kn = keyword_norm
|
|
517
|
+
|
|
518
|
+
# Bound per-keystroke work: stop scanning once enough matches are found.
|
|
519
|
+
matching_files: list[str] = []
|
|
520
|
+
scan_truncated = False
|
|
521
|
+
for p, pl in zip(all_files, all_files_lower, strict=False):
|
|
522
|
+
if kn in pl:
|
|
523
|
+
matching_files.append(p)
|
|
524
|
+
if len(matching_files) >= max_results:
|
|
525
|
+
scan_truncated = True
|
|
526
|
+
break
|
|
527
|
+
|
|
528
|
+
# Also include parent directories of matching files so users can
|
|
529
|
+
# complete into a folder, similar to fd's directory results.
|
|
530
|
+
dir_candidates: set[str] = set()
|
|
531
|
+
for p in matching_files[: max_results * 3]:
|
|
532
|
+
parent = os.path.dirname(p)
|
|
533
|
+
while parent and parent != ".":
|
|
534
|
+
dir_candidates.add(f"{parent}/")
|
|
535
|
+
parent = os.path.dirname(parent)
|
|
536
|
+
|
|
537
|
+
dir_list = sorted(dir_candidates)
|
|
538
|
+
dir_truncated = False
|
|
539
|
+
if len(dir_list) > max_results:
|
|
540
|
+
dir_list = dir_list[:max_results]
|
|
541
|
+
dir_truncated = True
|
|
542
|
+
|
|
543
|
+
candidates = matching_files + dir_list
|
|
544
|
+
truncated = scan_truncated or dir_truncated
|
|
545
|
+
return candidates, truncated
|
|
546
|
+
|
|
547
|
+
def _get_git_repo_root(self, cwd: Path) -> Path | None:
|
|
548
|
+
if not self._has_cmd("git"):
|
|
549
|
+
return None
|
|
412
550
|
|
|
413
|
-
|
|
414
|
-
|
|
551
|
+
now = time.monotonic()
|
|
552
|
+
ttl = max(self._cache_ttl, 30.0)
|
|
553
|
+
if self._git_repo_root_cwd == cwd and now - self._git_repo_root_time < ttl:
|
|
554
|
+
return self._git_repo_root
|
|
415
555
|
|
|
416
|
-
|
|
556
|
+
r = self._run_cmd(["git", "rev-parse", "--show-toplevel"], cwd=cwd, timeout_sec=0.5)
|
|
557
|
+
root = Path(r.lines[0]) if r.ok and r.lines else None
|
|
417
558
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
559
|
+
self._git_repo_root = root
|
|
560
|
+
self._git_repo_root_time = now
|
|
561
|
+
self._git_repo_root_cwd = cwd
|
|
562
|
+
return root
|
|
421
563
|
|
|
422
564
|
def _has_cmd(self, name: str) -> bool:
|
|
423
565
|
return shutil.which(name) is not None
|
|
@@ -443,7 +585,9 @@ class _AtFilesCompleter(Completer):
|
|
|
443
585
|
return []
|
|
444
586
|
return items[: min(self._max_results, 100)]
|
|
445
587
|
|
|
446
|
-
def _run_cmd(self, cmd: list[str], cwd: Path | None = None) -> _CmdResult:
|
|
588
|
+
def _run_cmd(self, cmd: list[str], cwd: Path | None = None, *, timeout_sec: float) -> _CmdResult:
|
|
589
|
+
cmd_str = " ".join(cmd)
|
|
590
|
+
start = time.monotonic()
|
|
447
591
|
try:
|
|
448
592
|
p = subprocess.run(
|
|
449
593
|
cmd,
|
|
@@ -451,11 +595,25 @@ class _AtFilesCompleter(Completer):
|
|
|
451
595
|
stdout=subprocess.PIPE,
|
|
452
596
|
stderr=subprocess.DEVNULL,
|
|
453
597
|
text=True,
|
|
454
|
-
timeout=
|
|
598
|
+
timeout=timeout_sec,
|
|
455
599
|
)
|
|
600
|
+
elapsed_ms = (time.monotonic() - start) * 1000
|
|
456
601
|
if p.returncode == 0:
|
|
457
602
|
lines = [ln.strip() for ln in p.stdout.splitlines() if ln.strip()]
|
|
603
|
+
log_debug(
|
|
604
|
+
f"[completer] cmd={cmd_str} elapsed={elapsed_ms:.1f}ms results={len(lines)}",
|
|
605
|
+
debug_type=DebugType.EXECUTION,
|
|
606
|
+
)
|
|
458
607
|
return _CmdResult(True, lines)
|
|
608
|
+
log_debug(
|
|
609
|
+
f"[completer] cmd={cmd_str} elapsed={elapsed_ms:.1f}ms returncode={p.returncode}",
|
|
610
|
+
debug_type=DebugType.EXECUTION,
|
|
611
|
+
)
|
|
459
612
|
return _CmdResult(False, [])
|
|
460
|
-
except Exception:
|
|
613
|
+
except Exception as e:
|
|
614
|
+
elapsed_ms = (time.monotonic() - start) * 1000
|
|
615
|
+
log_debug(
|
|
616
|
+
f"[completer] cmd={cmd_str} elapsed={elapsed_ms:.1f}ms error={e!r}",
|
|
617
|
+
debug_type=DebugType.EXECUTION,
|
|
618
|
+
)
|
|
461
619
|
return _CmdResult(False, [])
|
|
@@ -9,6 +9,7 @@ from klaude_code import const
|
|
|
9
9
|
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
|
+
from klaude_code.ui.renderers.thinking import normalize_thinking_content
|
|
12
13
|
from klaude_code.ui.rich.markdown import MarkdownStream, ThinkingMarkdown
|
|
13
14
|
from klaude_code.ui.rich.theme import ThemeKey
|
|
14
15
|
from klaude_code.ui.terminal.notifier import Notification, NotificationType, TerminalNotifier
|
|
@@ -121,7 +122,7 @@ class ActivityState:
|
|
|
121
122
|
for name, count in self._tool_calls.items():
|
|
122
123
|
if not first:
|
|
123
124
|
activity_text.append(", ")
|
|
124
|
-
activity_text.append(name)
|
|
125
|
+
activity_text.append(Text(name, style=ThemeKey.SPINNER_STATUS_TEXT_BOLD))
|
|
125
126
|
if count > 1:
|
|
126
127
|
activity_text.append(f" x {count}")
|
|
127
128
|
first = False
|
|
@@ -348,7 +349,7 @@ class DisplayEventHandler:
|
|
|
348
349
|
self.thinking_stream.append(event.content)
|
|
349
350
|
|
|
350
351
|
if first_delta and self.thinking_stream.mdstream is not None:
|
|
351
|
-
self.thinking_stream.mdstream.update(self.thinking_stream.buffer)
|
|
352
|
+
self.thinking_stream.mdstream.update(normalize_thinking_content(self.thinking_stream.buffer))
|
|
352
353
|
|
|
353
354
|
await self.stage_manager.enter_thinking_stage()
|
|
354
355
|
self.thinking_stream.debouncer.schedule()
|
|
@@ -415,10 +416,11 @@ class DisplayEventHandler:
|
|
|
415
416
|
self.renderer.display_tool_call(event)
|
|
416
417
|
|
|
417
418
|
async def _on_tool_result(self, event: events.ToolResultEvent) -> None:
|
|
418
|
-
if self.renderer.is_sub_agent_session(event.session_id):
|
|
419
|
+
if self.renderer.is_sub_agent_session(event.session_id) and event.status == "success":
|
|
419
420
|
return
|
|
420
421
|
await self.stage_manager.transition_to(Stage.TOOL_RESULT)
|
|
421
|
-
self.renderer.
|
|
422
|
+
with self.renderer.session_print_context(event.session_id):
|
|
423
|
+
self.renderer.display_tool_call_result(event)
|
|
422
424
|
|
|
423
425
|
def _on_task_metadata(self, event: events.TaskMetadataEvent) -> None:
|
|
424
426
|
self.renderer.display_task_metadata(event)
|
|
@@ -498,14 +500,14 @@ class DisplayEventHandler:
|
|
|
498
500
|
if state.is_active:
|
|
499
501
|
mdstream = state.mdstream
|
|
500
502
|
assert mdstream is not None
|
|
501
|
-
mdstream.update(state.buffer)
|
|
503
|
+
mdstream.update(normalize_thinking_content(state.buffer))
|
|
502
504
|
|
|
503
505
|
async def _finish_thinking_stream(self) -> None:
|
|
504
506
|
if self.thinking_stream.is_active:
|
|
505
507
|
self.thinking_stream.debouncer.cancel()
|
|
506
508
|
mdstream = self.thinking_stream.mdstream
|
|
507
509
|
assert mdstream is not None
|
|
508
|
-
mdstream.update(self.thinking_stream.buffer, final=True)
|
|
510
|
+
mdstream.update(normalize_thinking_content(self.thinking_stream.buffer), final=True)
|
|
509
511
|
self.thinking_stream.finish()
|
|
510
512
|
self.renderer.console.pop_theme()
|
|
511
513
|
self.renderer.print()
|
|
@@ -144,7 +144,7 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
144
144
|
|
|
145
145
|
# Build result with style
|
|
146
146
|
toolbar_text = left_text + padding + right_text
|
|
147
|
-
return FormattedText([("#
|
|
147
|
+
return FormattedText([("#2c7eac", toolbar_text)])
|
|
148
148
|
|
|
149
149
|
async def start(self) -> None:
|
|
150
150
|
pass
|
|
@@ -22,11 +22,11 @@ from klaude_code.ui.renderers import sub_agent as r_sub_agent
|
|
|
22
22
|
from klaude_code.ui.renderers import thinking as r_thinking
|
|
23
23
|
from klaude_code.ui.renderers import tools as r_tools
|
|
24
24
|
from klaude_code.ui.renderers import user_input as r_user_input
|
|
25
|
+
from klaude_code.ui.renderers.common import truncate_display
|
|
25
26
|
from klaude_code.ui.rich import status as r_status
|
|
26
27
|
from klaude_code.ui.rich.quote import Quote
|
|
27
28
|
from klaude_code.ui.rich.status import ShimmerStatusText
|
|
28
29
|
from klaude_code.ui.rich.theme import ThemeKey, get_theme
|
|
29
|
-
from klaude_code.ui.utils.common import truncate_display
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
@dataclass
|
|
@@ -244,7 +244,7 @@ class REPLRenderer:
|
|
|
244
244
|
def display_error(self, event: events.ErrorEvent) -> None:
|
|
245
245
|
self.print(
|
|
246
246
|
r_errors.render_error(
|
|
247
|
-
|
|
247
|
+
truncate_display(event.error_message),
|
|
248
248
|
indent=0,
|
|
249
249
|
)
|
|
250
250
|
)
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
from rich.style import Style
|
|
1
2
|
from rich.table import Table
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
|
|
5
|
+
from klaude_code import const
|
|
6
|
+
from klaude_code.ui.rich.theme import ThemeKey
|
|
2
7
|
|
|
3
8
|
|
|
4
9
|
def create_grid() -> Table:
|
|
@@ -6,3 +11,52 @@ def create_grid() -> Table:
|
|
|
6
11
|
grid.add_column(no_wrap=True)
|
|
7
12
|
grid.add_column(overflow="fold")
|
|
8
13
|
return grid
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def truncate_display(
|
|
17
|
+
text: str,
|
|
18
|
+
max_lines: int = const.TRUNCATE_DISPLAY_MAX_LINES,
|
|
19
|
+
max_line_length: int = const.TRUNCATE_DISPLAY_MAX_LINE_LENGTH,
|
|
20
|
+
*,
|
|
21
|
+
base_style: str | Style | None = None,
|
|
22
|
+
) -> Text:
|
|
23
|
+
"""Truncate long text for terminal display.
|
|
24
|
+
|
|
25
|
+
Applies `ThemeKey.TOOL_RESULT_TRUNCATED` style to truncation indicators.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
if max_lines <= 0:
|
|
29
|
+
truncated_lines = text.split("\n")
|
|
30
|
+
remaining = max(0, len(truncated_lines))
|
|
31
|
+
return Text(f"… (more {remaining} lines)", style=ThemeKey.TOOL_RESULT_TRUNCATED)
|
|
32
|
+
|
|
33
|
+
lines = text.split("\n")
|
|
34
|
+
extra_lines = 0
|
|
35
|
+
if len(lines) > max_lines:
|
|
36
|
+
extra_lines = len(lines) - max_lines
|
|
37
|
+
lines = lines[:max_lines]
|
|
38
|
+
|
|
39
|
+
out = Text()
|
|
40
|
+
if base_style is not None:
|
|
41
|
+
out.style = base_style
|
|
42
|
+
|
|
43
|
+
for idx, line in enumerate(lines):
|
|
44
|
+
if len(line) > max_line_length:
|
|
45
|
+
extra_chars = len(line) - max_line_length
|
|
46
|
+
out.append(line[:max_line_length])
|
|
47
|
+
out.append_text(
|
|
48
|
+
Text(
|
|
49
|
+
f" … (more {extra_chars} characters in this line)",
|
|
50
|
+
style=ThemeKey.TOOL_RESULT_TRUNCATED,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
else:
|
|
54
|
+
out.append(line)
|
|
55
|
+
|
|
56
|
+
if idx != len(lines) - 1 or extra_lines > 0:
|
|
57
|
+
out.append("\n")
|
|
58
|
+
|
|
59
|
+
if extra_lines > 0:
|
|
60
|
+
out.append_text(Text(f"… (more {extra_lines} lines)", style=ThemeKey.TOOL_RESULT_TRUNCATED))
|
|
61
|
+
|
|
62
|
+
return out
|
|
@@ -5,11 +5,10 @@ from rich.text import Text
|
|
|
5
5
|
|
|
6
6
|
from klaude_code.protocol import commands, events, model
|
|
7
7
|
from klaude_code.ui.renderers import diffs as r_diffs
|
|
8
|
-
from klaude_code.ui.renderers.common import create_grid
|
|
8
|
+
from klaude_code.ui.renderers.common import create_grid, truncate_display
|
|
9
9
|
from klaude_code.ui.renderers.tools import render_path
|
|
10
10
|
from klaude_code.ui.rich.markdown import NoInsetMarkdown
|
|
11
11
|
from klaude_code.ui.rich.theme import ThemeKey
|
|
12
|
-
from klaude_code.ui.utils.common import truncate_display
|
|
13
12
|
|
|
14
13
|
|
|
15
14
|
def need_render_developer_message(e: events.DeveloperMessageEvent) -> bool:
|
|
@@ -117,7 +116,7 @@ def render_command_output(e: events.DeveloperMessageEvent) -> RenderableType:
|
|
|
117
116
|
case _:
|
|
118
117
|
content = e.item.content or "(no content)"
|
|
119
118
|
style = ThemeKey.TOOL_RESULT if not e.item.command_output.is_error else ThemeKey.ERROR
|
|
120
|
-
return Padding.indent(
|
|
119
|
+
return Padding.indent(truncate_display(content, base_style=style), level=2)
|
|
121
120
|
|
|
122
121
|
|
|
123
122
|
def _format_tokens(tokens: int) -> str:
|
|
@@ -11,6 +11,6 @@ def render_error(error_msg: Text, indent: int = 2) -> RenderableType:
|
|
|
11
11
|
Shows a two-column grid with an error mark and truncated message.
|
|
12
12
|
"""
|
|
13
13
|
grid = create_grid()
|
|
14
|
-
error_msg.
|
|
14
|
+
error_msg.style = ThemeKey.ERROR
|
|
15
15
|
grid.add_row(Text(" " * indent + "✘", style=ThemeKey.ERROR_BOLD), error_msg)
|
|
16
16
|
return grid
|
|
@@ -61,9 +61,7 @@ def _render_task_metadata_block(
|
|
|
61
61
|
if metadata.usage is not None:
|
|
62
62
|
# Tokens: ↑ 37k cache 5k ↓ 907 think 45k
|
|
63
63
|
token_parts: list[Text] = [
|
|
64
|
-
Text.assemble(
|
|
65
|
-
("↑ ", ThemeKey.METADATA_DIM), (format_number(metadata.usage.input_tokens), ThemeKey.METADATA)
|
|
66
|
-
)
|
|
64
|
+
Text.assemble(("↑", ThemeKey.METADATA_DIM), (format_number(metadata.usage.input_tokens), ThemeKey.METADATA))
|
|
67
65
|
]
|
|
68
66
|
if metadata.usage.cached_tokens > 0:
|
|
69
67
|
token_parts.append(
|
|
@@ -74,7 +72,7 @@ def _render_task_metadata_block(
|
|
|
74
72
|
)
|
|
75
73
|
token_parts.append(
|
|
76
74
|
Text.assemble(
|
|
77
|
-
("↓
|
|
75
|
+
("↓", ThemeKey.METADATA_DIM), (format_number(metadata.usage.output_tokens), ThemeKey.METADATA)
|
|
78
76
|
)
|
|
79
77
|
)
|
|
80
78
|
if metadata.usage.reasoning_tokens > 0:
|
|
@@ -110,7 +108,16 @@ def _render_task_metadata_block(
|
|
|
110
108
|
parts.append(
|
|
111
109
|
Text.assemble(
|
|
112
110
|
(f"{metadata.usage.throughput_tps:.1f} ", ThemeKey.METADATA),
|
|
113
|
-
("tps", ThemeKey.METADATA_DIM),
|
|
111
|
+
("avg-tps", ThemeKey.METADATA_DIM),
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# First token latency
|
|
116
|
+
if metadata.usage.first_token_latency_ms is not None:
|
|
117
|
+
parts.append(
|
|
118
|
+
Text.assemble(
|
|
119
|
+
(f"{metadata.usage.first_token_latency_ms:.0f}", ThemeKey.METADATA),
|
|
120
|
+
("ms avg-ftl", ThemeKey.METADATA_DIM),
|
|
114
121
|
)
|
|
115
122
|
)
|
|
116
123
|
|