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.
Files changed (70) hide show
  1. klaude_code/cli/config_cmd.py +1 -1
  2. klaude_code/cli/debug.py +1 -1
  3. klaude_code/cli/main.py +45 -31
  4. klaude_code/cli/runtime.py +49 -13
  5. klaude_code/{version.py → cli/self_update.py} +110 -2
  6. klaude_code/command/__init__.py +4 -1
  7. klaude_code/command/clear_cmd.py +2 -7
  8. klaude_code/command/command_abc.py +33 -5
  9. klaude_code/command/debug_cmd.py +79 -0
  10. klaude_code/command/diff_cmd.py +2 -6
  11. klaude_code/command/export_cmd.py +7 -7
  12. klaude_code/command/export_online_cmd.py +9 -8
  13. klaude_code/command/help_cmd.py +4 -9
  14. klaude_code/command/model_cmd.py +10 -6
  15. klaude_code/command/prompt_command.py +2 -6
  16. klaude_code/command/refresh_cmd.py +2 -7
  17. klaude_code/command/registry.py +69 -26
  18. klaude_code/command/release_notes_cmd.py +2 -6
  19. klaude_code/command/status_cmd.py +2 -7
  20. klaude_code/command/terminal_setup_cmd.py +2 -6
  21. klaude_code/command/thinking_cmd.py +16 -10
  22. klaude_code/config/select_model.py +81 -5
  23. klaude_code/const/__init__.py +1 -1
  24. klaude_code/core/executor.py +257 -110
  25. klaude_code/core/manager/__init__.py +2 -4
  26. klaude_code/core/prompts/prompt-claude-code.md +1 -1
  27. klaude_code/core/prompts/prompt-sub-agent-explore.md +14 -2
  28. klaude_code/core/prompts/prompt-sub-agent-web.md +8 -5
  29. klaude_code/core/reminders.py +9 -35
  30. klaude_code/core/task.py +9 -7
  31. klaude_code/core/tool/file/read_tool.md +1 -1
  32. klaude_code/core/tool/file/read_tool.py +41 -12
  33. klaude_code/core/tool/memory/skill_loader.py +12 -10
  34. klaude_code/core/tool/shell/bash_tool.py +22 -2
  35. klaude_code/core/tool/tool_registry.py +1 -1
  36. klaude_code/core/tool/tool_runner.py +26 -23
  37. klaude_code/core/tool/truncation.py +23 -9
  38. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  39. klaude_code/core/tool/web/web_fetch_tool.py +36 -1
  40. klaude_code/core/turn.py +28 -0
  41. klaude_code/llm/anthropic/client.py +25 -9
  42. klaude_code/llm/openai_compatible/client.py +5 -2
  43. klaude_code/llm/openrouter/client.py +7 -3
  44. klaude_code/llm/responses/client.py +6 -1
  45. klaude_code/protocol/commands.py +1 -0
  46. klaude_code/protocol/sub_agent/web.py +3 -2
  47. klaude_code/session/session.py +35 -15
  48. klaude_code/session/templates/export_session.html +45 -32
  49. klaude_code/trace/__init__.py +20 -2
  50. klaude_code/ui/modes/repl/completers.py +231 -73
  51. klaude_code/ui/modes/repl/event_handler.py +8 -6
  52. klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
  53. klaude_code/ui/modes/repl/renderer.py +2 -2
  54. klaude_code/ui/renderers/common.py +54 -0
  55. klaude_code/ui/renderers/developer.py +2 -3
  56. klaude_code/ui/renderers/errors.py +1 -1
  57. klaude_code/ui/renderers/metadata.py +12 -5
  58. klaude_code/ui/renderers/thinking.py +24 -8
  59. klaude_code/ui/renderers/tools.py +82 -14
  60. klaude_code/ui/rich/code_panel.py +112 -0
  61. klaude_code/ui/rich/markdown.py +3 -4
  62. klaude_code/ui/rich/status.py +0 -2
  63. klaude_code/ui/rich/theme.py +10 -1
  64. klaude_code/ui/utils/common.py +0 -18
  65. {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/METADATA +32 -7
  66. {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/RECORD +69 -68
  67. klaude_code/core/manager/agent_manager.py +0 -132
  68. /klaude_code/{config → cli}/list_model.py +0 -0
  69. {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/WHEEL +0 -0
  70. {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 = " [instructions]" if cmd_obj.support_addition_params else ""
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 = 10.0,
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
- # Cache for ignored paths (gitignored files)
178
- self._last_ignored_paths: set[str] = set()
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
- return self._filter_and_format(self._last_results, cwd, key_norm, self._last_ignored_paths)
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, self._last_ignored_paths)
287
+ return self._filter_and_format(self._last_results, cwd, key_norm)
256
288
 
257
- # Prefer fd; otherwise fallback to rg --files
289
+ # Prefer git index (fast in large repos), then fd, then rg --files.
258
290
  results: list[str] = []
259
- ignored_paths: set[str] = set()
260
- if self._has_cmd("fd"):
261
- # Use fd to search anywhere in full path (files and directories), case-insensitive
262
- results, ignored_paths = self._run_fd_search(cwd, key_norm)
263
- elif self._has_cmd("rg"):
264
- # Use rg to search only in current directory
265
- if self._rg_file_list is None or now - self._rg_file_list_time > max(self._cache_ttl, 30.0):
266
- cmd = ["rg", "--files", "--no-ignore", "--hidden"]
267
- r = self._run_cmd(cmd, cwd=cwd) # Search from current directory
268
- if r.ok:
269
- self._rg_file_list = r.lines
270
- self._rg_file_list_time = now
271
- else:
272
- self._rg_file_list = []
273
- self._rg_file_list_time = now
274
- # Filter by keyword
275
- all_files = self._rg_file_list or []
276
- kn = key_norm
277
- results = [p for p in all_files if kn in p.lower()]
278
- # For rg fallback, we don't distinguish ignored files (no priority sorting)
279
- else:
280
- return []
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._last_ignored_paths = ignored_paths
288
- return self._filter_and_format(results, cwd, key_norm, ignored_paths)
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. Non-gitignored files first (is_ignored: 0 or 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
- ignored_paths = ignored_paths or set()
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
- # Use path directly since it's already relative to current directory
310
- rel_to_cwd = p.lstrip("./")
311
- base = os.path.basename(p).lower()
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], set[str]]:
374
- """Run fd search and return (all_results, ignored_paths).
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
- First runs fd without --no-ignore to get tracked files,
377
- then runs with --no-ignore to get all files including gitignored ones.
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
- pattern = self._escape_regex(keyword_norm)
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(self._max_results * 3),
456
+ str(max_results),
393
457
  "--exclude",
394
458
  ".git",
395
459
  "--exclude",
396
460
  ".venv",
397
461
  "--exclude",
398
462
  "node_modules",
399
- pattern,
463
+ keyword_norm,
400
464
  ".",
401
465
  ]
402
466
 
403
- # First run: get tracked (non-ignored) files
404
- r_tracked = self._run_cmd(base_cmd, cwd=cwd)
405
- tracked_paths: set[str] = set(p.lstrip("./") for p in r_tracked.lines) if r_tracked.ok else set()
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
- # Second run: get all files including ignored ones
408
- cmd_all = base_cmd.copy()
409
- cmd_all.insert(2, "--no-ignore") # Insert after --color=never
410
- r_all = self._run_cmd(cmd_all, cwd=cwd)
411
- all_paths = r_all.lines if r_all.ok else []
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
- # Calculate which paths are gitignored (in all but not in tracked)
414
- ignored_paths = set(p.lstrip("./") for p in all_paths) - tracked_paths
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
- return all_paths, ignored_paths
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
- def _escape_regex(self, s: str) -> str:
419
- # Escape for fd (regex by default). Keep '/' as is for path boundaries.
420
- return re.escape(s).replace("/", "/")
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=1.5,
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.display_tool_call_result(event)
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([("#ansiblue", toolbar_text)])
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
- self.console.render_str(truncate_display(event.error_message)),
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(Text(truncate_display(content), style=style), level=2)
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.stylize(ThemeKey.ERROR)
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
- ("↓ ", ThemeKey.METADATA_DIM), (format_number(metadata.usage.output_tokens), ThemeKey.METADATA)
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