klaude-code 1.2.18__py3-none-any.whl → 1.2.20__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/main.py +42 -22
- klaude_code/cli/runtime.py +46 -2
- klaude_code/{version.py → cli/self_update.py} +110 -2
- klaude_code/command/__init__.py +1 -3
- klaude_code/command/clear_cmd.py +5 -4
- klaude_code/command/command_abc.py +5 -40
- klaude_code/command/debug_cmd.py +2 -2
- klaude_code/command/diff_cmd.py +2 -1
- klaude_code/command/export_cmd.py +14 -49
- klaude_code/command/export_online_cmd.py +10 -4
- klaude_code/command/help_cmd.py +2 -1
- klaude_code/command/model_cmd.py +7 -5
- klaude_code/command/prompt-jj-workspace.md +18 -0
- klaude_code/command/prompt_command.py +16 -9
- klaude_code/command/refresh_cmd.py +3 -2
- klaude_code/command/registry.py +98 -28
- klaude_code/command/release_notes_cmd.py +2 -1
- klaude_code/command/status_cmd.py +2 -1
- klaude_code/command/terminal_setup_cmd.py +2 -1
- klaude_code/command/thinking_cmd.py +6 -4
- klaude_code/core/executor.py +187 -180
- klaude_code/core/manager/sub_agent_manager.py +3 -0
- klaude_code/core/prompt.py +4 -1
- klaude_code/core/prompts/prompt-sub-agent-explore.md +14 -2
- klaude_code/core/prompts/prompt-sub-agent-web.md +3 -3
- klaude_code/core/reminders.py +70 -26
- klaude_code/core/task.py +13 -12
- klaude_code/core/tool/__init__.py +2 -0
- klaude_code/core/tool/file/apply_patch_tool.py +3 -1
- klaude_code/core/tool/file/edit_tool.py +7 -5
- klaude_code/core/tool/file/multi_edit_tool.py +7 -5
- klaude_code/core/tool/file/read_tool.md +1 -1
- klaude_code/core/tool/file/read_tool.py +8 -4
- klaude_code/core/tool/file/write_tool.py +8 -6
- klaude_code/core/tool/memory/skill_loader.py +12 -10
- klaude_code/core/tool/shell/bash_tool.py +89 -17
- klaude_code/core/tool/sub_agent_tool.py +5 -1
- klaude_code/core/tool/tool_abc.py +18 -0
- klaude_code/core/tool/tool_context.py +6 -6
- klaude_code/core/tool/tool_registry.py +1 -1
- klaude_code/core/tool/tool_runner.py +7 -7
- klaude_code/core/tool/web/web_fetch_tool.py +77 -22
- klaude_code/core/tool/web/web_search_tool.py +5 -1
- 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/model.py +8 -1
- klaude_code/protocol/op.py +47 -0
- klaude_code/protocol/op_handler.py +25 -1
- klaude_code/protocol/sub_agent/web.py +1 -1
- klaude_code/session/codec.py +71 -0
- klaude_code/session/export.py +21 -11
- klaude_code/session/session.py +186 -322
- klaude_code/session/store.py +215 -0
- klaude_code/session/templates/export_session.html +48 -47
- klaude_code/ui/modes/repl/completers.py +211 -71
- klaude_code/ui/modes/repl/event_handler.py +7 -23
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +5 -7
- 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 +10 -1
- klaude_code/ui/renderers/tools.py +3 -4
- klaude_code/ui/rich/__init__.py +10 -1
- klaude_code/ui/rich/cjk_wrap.py +228 -0
- klaude_code/ui/rich/status.py +0 -1
- klaude_code/ui/utils/common.py +0 -18
- {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/METADATA +18 -2
- {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/RECORD +73 -70
- klaude_code/ui/utils/debouncer.py +0 -42
- {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/entry_points.txt +0 -0
|
@@ -158,7 +158,7 @@ class _AtFilesCompleter(Completer):
|
|
|
158
158
|
def __init__(
|
|
159
159
|
self,
|
|
160
160
|
debounce_sec: float = 0.25,
|
|
161
|
-
cache_ttl_sec: float =
|
|
161
|
+
cache_ttl_sec: float = 60.0,
|
|
162
162
|
max_results: int = 20,
|
|
163
163
|
):
|
|
164
164
|
self._debounce_sec = debounce_sec
|
|
@@ -170,13 +170,26 @@ class _AtFilesCompleter(Completer):
|
|
|
170
170
|
self._last_query_key: str | None = None
|
|
171
171
|
self._last_results: list[str] = []
|
|
172
172
|
self._last_results_time: float = 0.0
|
|
173
|
+
self._last_results_truncated: bool = False
|
|
173
174
|
|
|
174
175
|
# rg --files cache (used when fd is unavailable)
|
|
175
176
|
self._rg_file_list: list[str] | None = None
|
|
176
177
|
self._rg_file_list_time: float = 0.0
|
|
178
|
+
self._rg_file_list_cwd: Path | None = None
|
|
177
179
|
|
|
178
|
-
#
|
|
179
|
-
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
|
|
180
193
|
|
|
181
194
|
# ---- prompt_toolkit API ----
|
|
182
195
|
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: # type: ignore[override]
|
|
@@ -234,6 +247,8 @@ class _AtFilesCompleter(Completer):
|
|
|
234
247
|
key_norm = keyword.lower()
|
|
235
248
|
query_key = f"{cwd.resolve()}::search::{key_norm}"
|
|
236
249
|
|
|
250
|
+
max_scan_results = self._max_results * 3
|
|
251
|
+
|
|
237
252
|
# Debounce: if called too soon again, filter last results
|
|
238
253
|
if self._last_results and self._last_query_key is not None:
|
|
239
254
|
prev = self._last_query_key
|
|
@@ -247,85 +262,120 @@ class _AtFilesCompleter(Completer):
|
|
|
247
262
|
and len(cur_kw) >= len(prev_kw)
|
|
248
263
|
and cur_kw.startswith(prev_kw)
|
|
249
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
|
+
|
|
250
276
|
if is_narrowing and (now - self._last_cmd_time) < self._debounce_sec:
|
|
251
|
-
# For narrowing, fast-filter previous results to avoid expensive calls
|
|
252
|
-
|
|
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
|
|
253
284
|
|
|
254
285
|
# Cache TTL: reuse cached results for same query within TTL
|
|
255
286
|
if self._last_results and self._last_query_key == query_key and now - self._last_results_time < self._cache_ttl:
|
|
256
|
-
return self._filter_and_format(self._last_results, cwd, key_norm
|
|
287
|
+
return self._filter_and_format(self._last_results, cwd, key_norm)
|
|
257
288
|
|
|
258
|
-
# Prefer
|
|
289
|
+
# Prefer git index (fast in large repos), then fd, then rg --files.
|
|
259
290
|
results: list[str] = []
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
+
"--hidden",
|
|
315
|
+
"--glob",
|
|
316
|
+
"!**/.git/**",
|
|
317
|
+
"--glob",
|
|
318
|
+
"!**/.venv/**",
|
|
319
|
+
"--glob",
|
|
320
|
+
"!**/node_modules/**",
|
|
321
|
+
]
|
|
322
|
+
r = self._run_cmd(cmd, cwd=cwd, timeout_sec=self._cmd_timeout_sec) # Search from current directory
|
|
323
|
+
if r.ok:
|
|
324
|
+
self._rg_file_list = r.lines
|
|
325
|
+
self._rg_file_list_time = now
|
|
326
|
+
self._rg_file_list_cwd = cwd
|
|
327
|
+
else:
|
|
328
|
+
self._rg_file_list = []
|
|
329
|
+
self._rg_file_list_time = now
|
|
330
|
+
self._rg_file_list_cwd = cwd
|
|
331
|
+
# Filter by keyword
|
|
332
|
+
all_files = self._rg_file_list or []
|
|
333
|
+
kn = key_norm
|
|
334
|
+
results = [p for p in all_files if kn in p.lower()]
|
|
335
|
+
# For rg fallback, we don't implement any priority sorting.
|
|
336
|
+
else:
|
|
337
|
+
return []
|
|
282
338
|
|
|
283
339
|
# Update caches
|
|
284
340
|
self._last_cmd_time = now
|
|
285
341
|
self._last_query_key = query_key
|
|
286
342
|
self._last_results = results
|
|
287
343
|
self._last_results_time = now
|
|
288
|
-
self.
|
|
289
|
-
return self._filter_and_format(results, cwd, key_norm
|
|
344
|
+
self._last_results_truncated = truncated
|
|
345
|
+
return self._filter_and_format(results, cwd, key_norm)
|
|
290
346
|
|
|
291
347
|
def _filter_and_format(
|
|
292
348
|
self,
|
|
293
349
|
paths_from_root: list[str],
|
|
294
350
|
cwd: Path,
|
|
295
351
|
keyword_norm: str,
|
|
296
|
-
ignored_paths: set[str] | None = None,
|
|
297
352
|
) -> list[str]:
|
|
298
353
|
# Filter to keyword (case-insensitive) and rank by:
|
|
299
|
-
# 1.
|
|
300
|
-
# 2. Basename hit first, then path hit position, then length
|
|
354
|
+
# 1. Basename hit first, then path hit position, then length
|
|
301
355
|
# Since both fd and rg now search from current directory, all paths are relative to cwd
|
|
302
356
|
kn = keyword_norm
|
|
303
|
-
|
|
304
|
-
out: list[tuple[str, tuple[int, int, int, int, int]]] = []
|
|
357
|
+
out: list[tuple[str, tuple[int, int, int, int]]] = []
|
|
305
358
|
for p in paths_from_root:
|
|
306
359
|
pl = p.lower()
|
|
307
360
|
if kn not in pl:
|
|
308
361
|
continue
|
|
309
362
|
|
|
310
|
-
#
|
|
311
|
-
|
|
312
|
-
|
|
363
|
+
# Most tools return paths relative to cwd. Some include a leading
|
|
364
|
+
# './' prefix; strip that exact prefix only.
|
|
365
|
+
#
|
|
366
|
+
# Do not use lstrip('./') here: it would also remove the leading '.'
|
|
367
|
+
# from dotfiles/directories like '.claude/'.
|
|
368
|
+
rel_to_cwd = p.removeprefix("./").removeprefix(".\\")
|
|
369
|
+
base = os.path.basename(rel_to_cwd.rstrip("/")).lower()
|
|
313
370
|
base_pos = base.find(kn)
|
|
314
371
|
path_pos = pl.find(kn)
|
|
315
|
-
# Check if this path is in the ignored set (gitignored files)
|
|
316
|
-
is_ignored = 1 if rel_to_cwd in ignored_paths else 0
|
|
317
372
|
score = (
|
|
318
|
-
is_ignored,
|
|
319
373
|
0 if base_pos != -1 else 1,
|
|
320
374
|
base_pos if base_pos != -1 else 10_000,
|
|
321
375
|
path_pos,
|
|
322
376
|
len(p),
|
|
323
377
|
)
|
|
324
378
|
|
|
325
|
-
# Append trailing slash for directories
|
|
326
|
-
full_path = cwd / rel_to_cwd
|
|
327
|
-
if full_path.is_dir() and not rel_to_cwd.endswith("/"):
|
|
328
|
-
rel_to_cwd = rel_to_cwd + "/"
|
|
329
379
|
out.append((rel_to_cwd, score))
|
|
330
380
|
# Sort by score
|
|
331
381
|
out.sort(key=lambda x: x[1])
|
|
@@ -336,6 +386,19 @@ class _AtFilesCompleter(Completer):
|
|
|
336
386
|
if s not in seen:
|
|
337
387
|
seen.add(s)
|
|
338
388
|
uniq.append(s)
|
|
389
|
+
|
|
390
|
+
# Append trailing slash for directories, but avoid excessive stats.
|
|
391
|
+
# For large candidate lists, only stat the most relevant prefixes.
|
|
392
|
+
stat_limit = min(len(uniq), max(self._max_results * 3, 60))
|
|
393
|
+
for idx in range(stat_limit):
|
|
394
|
+
s = uniq[idx]
|
|
395
|
+
if s.endswith("/"):
|
|
396
|
+
continue
|
|
397
|
+
try:
|
|
398
|
+
if (cwd / s).is_dir():
|
|
399
|
+
uniq[idx] = f"{s}/"
|
|
400
|
+
except Exception:
|
|
401
|
+
continue
|
|
339
402
|
return uniq
|
|
340
403
|
|
|
341
404
|
def _format_completion_text(self, suggestion: str, *, is_quoted: bool) -> str:
|
|
@@ -371,15 +434,13 @@ class _AtFilesCompleter(Completer):
|
|
|
371
434
|
return None, None
|
|
372
435
|
|
|
373
436
|
# ---- Utilities ----
|
|
374
|
-
def _run_fd_search(self, cwd: Path, keyword_norm: str) -> tuple[list[str],
|
|
375
|
-
"""Run fd search and return (
|
|
437
|
+
def _run_fd_search(self, cwd: Path, keyword_norm: str, *, max_results: int) -> tuple[list[str], bool]:
|
|
438
|
+
"""Run fd search and return (results, truncated).
|
|
376
439
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
Returns the combined results and a set of paths that are gitignored.
|
|
440
|
+
Note: This is called in the prompt_toolkit completion path, so avoid
|
|
441
|
+
doing extra background scans here.
|
|
380
442
|
"""
|
|
381
|
-
|
|
382
|
-
base_cmd = [
|
|
443
|
+
cmd = [
|
|
383
444
|
"fd",
|
|
384
445
|
"--color=never",
|
|
385
446
|
"--type",
|
|
@@ -389,36 +450,115 @@ class _AtFilesCompleter(Completer):
|
|
|
389
450
|
"--hidden",
|
|
390
451
|
"--full-path",
|
|
391
452
|
"-i",
|
|
453
|
+
"-F",
|
|
392
454
|
"--max-results",
|
|
393
|
-
str(
|
|
455
|
+
str(max_results),
|
|
394
456
|
"--exclude",
|
|
395
457
|
".git",
|
|
396
458
|
"--exclude",
|
|
397
459
|
".venv",
|
|
398
460
|
"--exclude",
|
|
399
461
|
"node_modules",
|
|
400
|
-
|
|
462
|
+
keyword_norm,
|
|
401
463
|
".",
|
|
402
464
|
]
|
|
403
465
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
466
|
+
r = self._run_cmd(cmd, cwd=cwd, timeout_sec=self._cmd_timeout_sec)
|
|
467
|
+
lines = r.lines if r.ok else []
|
|
468
|
+
return lines, (len(lines) >= max_results)
|
|
469
|
+
|
|
470
|
+
def _git_paths_for_keyword(self, cwd: Path, keyword_norm: str, *, max_results: int) -> tuple[list[str], bool]:
|
|
471
|
+
"""Get path suggestions from the git index (fast for large repos).
|
|
472
|
+
|
|
473
|
+
Returns (candidates, truncated). "truncated" is True when we
|
|
474
|
+
intentionally stop early to keep per-keystroke costs bounded.
|
|
475
|
+
"""
|
|
476
|
+
repo_root = self._get_git_repo_root(cwd)
|
|
477
|
+
if repo_root is None:
|
|
478
|
+
return [], False
|
|
479
|
+
|
|
480
|
+
now = time.monotonic()
|
|
481
|
+
git_cache_ttl = max(self._cache_ttl, 30.0)
|
|
482
|
+
if (
|
|
483
|
+
self._git_file_list is None
|
|
484
|
+
or self._git_file_list_cwd != cwd
|
|
485
|
+
or now - self._git_file_list_time > git_cache_ttl
|
|
486
|
+
):
|
|
487
|
+
cmd = ["git", "ls-files", "-co", "--exclude-standard"]
|
|
488
|
+
r = self._run_cmd(cmd, cwd=repo_root, timeout_sec=self._cmd_timeout_sec)
|
|
489
|
+
if not r.ok:
|
|
490
|
+
self._git_file_list = []
|
|
491
|
+
self._git_file_list_lower = []
|
|
492
|
+
self._git_file_list_time = now
|
|
493
|
+
self._git_file_list_cwd = cwd
|
|
494
|
+
else:
|
|
495
|
+
cwd_resolved = cwd.resolve()
|
|
496
|
+
root_resolved = repo_root.resolve()
|
|
497
|
+
files: list[str] = []
|
|
498
|
+
files_lower: list[str] = []
|
|
499
|
+
for rel in r.lines:
|
|
500
|
+
abs_path = root_resolved / rel
|
|
501
|
+
try:
|
|
502
|
+
rel_to_cwd = abs_path.relative_to(cwd_resolved)
|
|
503
|
+
except ValueError:
|
|
504
|
+
continue
|
|
505
|
+
rel_posix = rel_to_cwd.as_posix()
|
|
506
|
+
files.append(rel_posix)
|
|
507
|
+
files_lower.append(rel_posix.lower())
|
|
508
|
+
self._git_file_list = files
|
|
509
|
+
self._git_file_list_lower = files_lower
|
|
510
|
+
self._git_file_list_time = now
|
|
511
|
+
self._git_file_list_cwd = cwd
|
|
512
|
+
|
|
513
|
+
all_files = self._git_file_list or []
|
|
514
|
+
all_files_lower = self._git_file_list_lower or []
|
|
515
|
+
kn = keyword_norm
|
|
407
516
|
|
|
408
|
-
#
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
517
|
+
# Bound per-keystroke work: stop scanning once enough matches are found.
|
|
518
|
+
matching_files: list[str] = []
|
|
519
|
+
scan_truncated = False
|
|
520
|
+
for p, pl in zip(all_files, all_files_lower, strict=False):
|
|
521
|
+
if kn in pl:
|
|
522
|
+
matching_files.append(p)
|
|
523
|
+
if len(matching_files) >= max_results:
|
|
524
|
+
scan_truncated = True
|
|
525
|
+
break
|
|
526
|
+
|
|
527
|
+
# Also include parent directories of matching files so users can
|
|
528
|
+
# complete into a folder, similar to fd's directory results.
|
|
529
|
+
dir_candidates: set[str] = set()
|
|
530
|
+
for p in matching_files[: max_results * 3]:
|
|
531
|
+
parent = os.path.dirname(p)
|
|
532
|
+
while parent and parent != ".":
|
|
533
|
+
dir_candidates.add(f"{parent}/")
|
|
534
|
+
parent = os.path.dirname(parent)
|
|
535
|
+
|
|
536
|
+
dir_list = sorted(dir_candidates)
|
|
537
|
+
dir_truncated = False
|
|
538
|
+
if len(dir_list) > max_results:
|
|
539
|
+
dir_list = dir_list[:max_results]
|
|
540
|
+
dir_truncated = True
|
|
541
|
+
|
|
542
|
+
candidates = matching_files + dir_list
|
|
543
|
+
truncated = scan_truncated or dir_truncated
|
|
544
|
+
return candidates, truncated
|
|
545
|
+
|
|
546
|
+
def _get_git_repo_root(self, cwd: Path) -> Path | None:
|
|
547
|
+
if not self._has_cmd("git"):
|
|
548
|
+
return None
|
|
413
549
|
|
|
414
|
-
|
|
415
|
-
|
|
550
|
+
now = time.monotonic()
|
|
551
|
+
ttl = max(self._cache_ttl, 30.0)
|
|
552
|
+
if self._git_repo_root_cwd == cwd and now - self._git_repo_root_time < ttl:
|
|
553
|
+
return self._git_repo_root
|
|
416
554
|
|
|
417
|
-
|
|
555
|
+
r = self._run_cmd(["git", "rev-parse", "--show-toplevel"], cwd=cwd, timeout_sec=0.5)
|
|
556
|
+
root = Path(r.lines[0]) if r.ok and r.lines else None
|
|
418
557
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
558
|
+
self._git_repo_root = root
|
|
559
|
+
self._git_repo_root_time = now
|
|
560
|
+
self._git_repo_root_cwd = cwd
|
|
561
|
+
return root
|
|
422
562
|
|
|
423
563
|
def _has_cmd(self, name: str) -> bool:
|
|
424
564
|
return shutil.which(name) is not None
|
|
@@ -444,7 +584,7 @@ class _AtFilesCompleter(Completer):
|
|
|
444
584
|
return []
|
|
445
585
|
return items[: min(self._max_results, 100)]
|
|
446
586
|
|
|
447
|
-
def _run_cmd(self, cmd: list[str], cwd: Path | None = None) -> _CmdResult:
|
|
587
|
+
def _run_cmd(self, cmd: list[str], cwd: Path | None = None, *, timeout_sec: float) -> _CmdResult:
|
|
448
588
|
cmd_str = " ".join(cmd)
|
|
449
589
|
start = time.monotonic()
|
|
450
590
|
try:
|
|
@@ -454,7 +594,7 @@ class _AtFilesCompleter(Completer):
|
|
|
454
594
|
stdout=subprocess.PIPE,
|
|
455
595
|
stderr=subprocess.DEVNULL,
|
|
456
596
|
text=True,
|
|
457
|
-
timeout=
|
|
597
|
+
timeout=timeout_sec,
|
|
458
598
|
)
|
|
459
599
|
elapsed_ms = (time.monotonic() - start) * 1000
|
|
460
600
|
if p.returncode == 0:
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from collections.abc import Awaitable, Callable
|
|
4
3
|
from dataclasses import dataclass
|
|
5
4
|
|
|
6
5
|
from rich.text import Text
|
|
@@ -14,7 +13,6 @@ from klaude_code.ui.rich.markdown import MarkdownStream, ThinkingMarkdown
|
|
|
14
13
|
from klaude_code.ui.rich.theme import ThemeKey
|
|
15
14
|
from klaude_code.ui.terminal.notifier import Notification, NotificationType, TerminalNotifier
|
|
16
15
|
from klaude_code.ui.terminal.progress_bar import OSC94States, emit_osc94
|
|
17
|
-
from klaude_code.ui.utils.debouncer import Debouncer
|
|
18
16
|
|
|
19
17
|
|
|
20
18
|
@dataclass
|
|
@@ -43,13 +41,8 @@ class StreamState:
|
|
|
43
41
|
This design ensures buffer and mdstream are always in sync.
|
|
44
42
|
"""
|
|
45
43
|
|
|
46
|
-
def __init__(self
|
|
44
|
+
def __init__(self) -> None:
|
|
47
45
|
self._active: ActiveStream | None = None
|
|
48
|
-
self._flush_handler = flush_handler
|
|
49
|
-
self.debouncer = Debouncer(interval=interval, callback=self._debounced_flush)
|
|
50
|
-
|
|
51
|
-
async def _debounced_flush(self) -> None:
|
|
52
|
-
await self._flush_handler(self)
|
|
53
46
|
|
|
54
47
|
@property
|
|
55
48
|
def is_active(self) -> bool:
|
|
@@ -218,12 +211,8 @@ class DisplayEventHandler:
|
|
|
218
211
|
def __init__(self, renderer: REPLRenderer, notifier: TerminalNotifier | None = None):
|
|
219
212
|
self.renderer = renderer
|
|
220
213
|
self.notifier = notifier
|
|
221
|
-
self.assistant_stream = StreamState(
|
|
222
|
-
|
|
223
|
-
)
|
|
224
|
-
self.thinking_stream = StreamState(
|
|
225
|
-
interval=1 / const.UI_REFRESH_RATE_FPS, flush_handler=self._flush_thinking_buffer
|
|
226
|
-
)
|
|
214
|
+
self.assistant_stream = StreamState()
|
|
215
|
+
self.thinking_stream = StreamState()
|
|
227
216
|
self.spinner_status = SpinnerStatusState()
|
|
228
217
|
|
|
229
218
|
self.stage_manager = StageManager(
|
|
@@ -280,10 +269,8 @@ class DisplayEventHandler:
|
|
|
280
269
|
await self._on_end(e)
|
|
281
270
|
|
|
282
271
|
async def stop(self) -> None:
|
|
283
|
-
await self.assistant_stream
|
|
284
|
-
self.
|
|
285
|
-
await self.thinking_stream.debouncer.flush()
|
|
286
|
-
self.thinking_stream.debouncer.cancel()
|
|
272
|
+
await self._flush_assistant_buffer(self.assistant_stream)
|
|
273
|
+
await self._flush_thinking_buffer(self.thinking_stream)
|
|
287
274
|
|
|
288
275
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
289
276
|
# Private event handlers
|
|
@@ -352,7 +339,7 @@ class DisplayEventHandler:
|
|
|
352
339
|
self.thinking_stream.mdstream.update(normalize_thinking_content(self.thinking_stream.buffer))
|
|
353
340
|
|
|
354
341
|
await self.stage_manager.enter_thinking_stage()
|
|
355
|
-
self.thinking_stream
|
|
342
|
+
await self._flush_thinking_buffer(self.thinking_stream)
|
|
356
343
|
|
|
357
344
|
async def _on_assistant_delta(self, event: events.AssistantMessageDeltaEvent) -> None:
|
|
358
345
|
if self.renderer.is_sub_agent_session(event.session_id):
|
|
@@ -383,14 +370,13 @@ class DisplayEventHandler:
|
|
|
383
370
|
self.renderer.spinner_stop()
|
|
384
371
|
self.assistant_stream.mdstream.update(self.assistant_stream.buffer)
|
|
385
372
|
await self.stage_manager.transition_to(Stage.ASSISTANT)
|
|
386
|
-
self.assistant_stream
|
|
373
|
+
await self._flush_assistant_buffer(self.assistant_stream)
|
|
387
374
|
|
|
388
375
|
async def _on_assistant_message(self, event: events.AssistantMessageEvent) -> None:
|
|
389
376
|
if self.renderer.is_sub_agent_session(event.session_id):
|
|
390
377
|
return
|
|
391
378
|
await self.stage_manager.transition_to(Stage.ASSISTANT)
|
|
392
379
|
if self.assistant_stream.is_active:
|
|
393
|
-
self.assistant_stream.debouncer.cancel()
|
|
394
380
|
mdstream = self.assistant_stream.mdstream
|
|
395
381
|
assert mdstream is not None
|
|
396
382
|
mdstream.update(event.content.strip(), final=True)
|
|
@@ -474,7 +460,6 @@ class DisplayEventHandler:
|
|
|
474
460
|
|
|
475
461
|
async def _finish_assistant_stream(self) -> None:
|
|
476
462
|
if self.assistant_stream.is_active:
|
|
477
|
-
self.assistant_stream.debouncer.cancel()
|
|
478
463
|
mdstream = self.assistant_stream.mdstream
|
|
479
464
|
assert mdstream is not None
|
|
480
465
|
mdstream.update(self.assistant_stream.buffer, final=True)
|
|
@@ -504,7 +489,6 @@ class DisplayEventHandler:
|
|
|
504
489
|
|
|
505
490
|
async def _finish_thinking_stream(self) -> None:
|
|
506
491
|
if self.thinking_stream.is_active:
|
|
507
|
-
self.thinking_stream.debouncer.cancel()
|
|
508
492
|
mdstream = self.thinking_stream.mdstream
|
|
509
493
|
assert mdstream is not None
|
|
510
494
|
mdstream.update(normalize_thinking_content(self.thinking_stream.buffer), final=True)
|
|
@@ -44,12 +44,10 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
44
44
|
self._status_provider = status_provider
|
|
45
45
|
|
|
46
46
|
project = str(Path.cwd()).strip("/").replace("/", "-")
|
|
47
|
-
history_path = Path.home() / ".klaude" / "projects" /
|
|
47
|
+
history_path = Path.home() / ".klaude" / "projects" / project / "input" / "input_history.txt"
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if not history_path.exists():
|
|
52
|
-
history_path.touch()
|
|
49
|
+
history_path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
history_path.touch(exist_ok=True)
|
|
53
51
|
|
|
54
52
|
# Create key bindings with injected dependencies
|
|
55
53
|
kb = create_key_bindings(
|
|
@@ -60,7 +58,7 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
60
58
|
|
|
61
59
|
self._session: PromptSession[str] = PromptSession(
|
|
62
60
|
[(INPUT_PROMPT_STYLE, prompt)],
|
|
63
|
-
history=FileHistory(history_path),
|
|
61
|
+
history=FileHistory(str(history_path)),
|
|
64
62
|
multiline=True,
|
|
65
63
|
prompt_continuation=[(INPUT_PROMPT_STYLE, " ")],
|
|
66
64
|
key_bindings=kb,
|
|
@@ -144,7 +142,7 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
144
142
|
|
|
145
143
|
# Build result with style
|
|
146
144
|
toolbar_text = left_text + padding + right_text
|
|
147
|
-
return FormattedText([("#
|
|
145
|
+
return FormattedText([("#2c7eac", toolbar_text)])
|
|
148
146
|
|
|
149
147
|
async def start(self) -> None:
|
|
150
148
|
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
|
|
@@ -108,7 +108,16 @@ def _render_task_metadata_block(
|
|
|
108
108
|
parts.append(
|
|
109
109
|
Text.assemble(
|
|
110
110
|
(f"{metadata.usage.throughput_tps:.1f} ", ThemeKey.METADATA),
|
|
111
|
-
("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),
|
|
112
121
|
)
|
|
113
122
|
)
|
|
114
123
|
|