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.
Files changed (74) hide show
  1. klaude_code/cli/main.py +42 -22
  2. klaude_code/cli/runtime.py +46 -2
  3. klaude_code/{version.py → cli/self_update.py} +110 -2
  4. klaude_code/command/__init__.py +1 -3
  5. klaude_code/command/clear_cmd.py +5 -4
  6. klaude_code/command/command_abc.py +5 -40
  7. klaude_code/command/debug_cmd.py +2 -2
  8. klaude_code/command/diff_cmd.py +2 -1
  9. klaude_code/command/export_cmd.py +14 -49
  10. klaude_code/command/export_online_cmd.py +10 -4
  11. klaude_code/command/help_cmd.py +2 -1
  12. klaude_code/command/model_cmd.py +7 -5
  13. klaude_code/command/prompt-jj-workspace.md +18 -0
  14. klaude_code/command/prompt_command.py +16 -9
  15. klaude_code/command/refresh_cmd.py +3 -2
  16. klaude_code/command/registry.py +98 -28
  17. klaude_code/command/release_notes_cmd.py +2 -1
  18. klaude_code/command/status_cmd.py +2 -1
  19. klaude_code/command/terminal_setup_cmd.py +2 -1
  20. klaude_code/command/thinking_cmd.py +6 -4
  21. klaude_code/core/executor.py +187 -180
  22. klaude_code/core/manager/sub_agent_manager.py +3 -0
  23. klaude_code/core/prompt.py +4 -1
  24. klaude_code/core/prompts/prompt-sub-agent-explore.md +14 -2
  25. klaude_code/core/prompts/prompt-sub-agent-web.md +3 -3
  26. klaude_code/core/reminders.py +70 -26
  27. klaude_code/core/task.py +13 -12
  28. klaude_code/core/tool/__init__.py +2 -0
  29. klaude_code/core/tool/file/apply_patch_tool.py +3 -1
  30. klaude_code/core/tool/file/edit_tool.py +7 -5
  31. klaude_code/core/tool/file/multi_edit_tool.py +7 -5
  32. klaude_code/core/tool/file/read_tool.md +1 -1
  33. klaude_code/core/tool/file/read_tool.py +8 -4
  34. klaude_code/core/tool/file/write_tool.py +8 -6
  35. klaude_code/core/tool/memory/skill_loader.py +12 -10
  36. klaude_code/core/tool/shell/bash_tool.py +89 -17
  37. klaude_code/core/tool/sub_agent_tool.py +5 -1
  38. klaude_code/core/tool/tool_abc.py +18 -0
  39. klaude_code/core/tool/tool_context.py +6 -6
  40. klaude_code/core/tool/tool_registry.py +1 -1
  41. klaude_code/core/tool/tool_runner.py +7 -7
  42. klaude_code/core/tool/web/web_fetch_tool.py +77 -22
  43. klaude_code/core/tool/web/web_search_tool.py +5 -1
  44. klaude_code/llm/anthropic/client.py +25 -9
  45. klaude_code/llm/openai_compatible/client.py +5 -2
  46. klaude_code/llm/openrouter/client.py +7 -3
  47. klaude_code/llm/responses/client.py +6 -1
  48. klaude_code/protocol/model.py +8 -1
  49. klaude_code/protocol/op.py +47 -0
  50. klaude_code/protocol/op_handler.py +25 -1
  51. klaude_code/protocol/sub_agent/web.py +1 -1
  52. klaude_code/session/codec.py +71 -0
  53. klaude_code/session/export.py +21 -11
  54. klaude_code/session/session.py +186 -322
  55. klaude_code/session/store.py +215 -0
  56. klaude_code/session/templates/export_session.html +48 -47
  57. klaude_code/ui/modes/repl/completers.py +211 -71
  58. klaude_code/ui/modes/repl/event_handler.py +7 -23
  59. klaude_code/ui/modes/repl/input_prompt_toolkit.py +5 -7
  60. klaude_code/ui/modes/repl/renderer.py +2 -2
  61. klaude_code/ui/renderers/common.py +54 -0
  62. klaude_code/ui/renderers/developer.py +2 -3
  63. klaude_code/ui/renderers/errors.py +1 -1
  64. klaude_code/ui/renderers/metadata.py +10 -1
  65. klaude_code/ui/renderers/tools.py +3 -4
  66. klaude_code/ui/rich/__init__.py +10 -1
  67. klaude_code/ui/rich/cjk_wrap.py +228 -0
  68. klaude_code/ui/rich/status.py +0 -1
  69. klaude_code/ui/utils/common.py +0 -18
  70. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/METADATA +18 -2
  71. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/RECORD +73 -70
  72. klaude_code/ui/utils/debouncer.py +0 -42
  73. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/WHEEL +0 -0
  74. {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 = 10.0,
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
- # Cache for ignored paths (gitignored files)
179
- 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
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
- 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
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, self._last_ignored_paths)
287
+ return self._filter_and_format(self._last_results, cwd, key_norm)
257
288
 
258
- # Prefer fd; otherwise fallback to rg --files
289
+ # Prefer git index (fast in large repos), then fd, then rg --files.
259
290
  results: list[str] = []
260
- ignored_paths: set[str] = set()
261
- if self._has_cmd("fd"):
262
- # Use fd to search anywhere in full path (files and directories), case-insensitive
263
- results, ignored_paths = self._run_fd_search(cwd, key_norm)
264
- elif self._has_cmd("rg"):
265
- # Use rg to search only in current directory
266
- if self._rg_file_list is None or now - self._rg_file_list_time > max(self._cache_ttl, 30.0):
267
- cmd = ["rg", "--files", "--no-ignore", "--hidden"]
268
- r = self._run_cmd(cmd, cwd=cwd) # Search from current directory
269
- if r.ok:
270
- self._rg_file_list = r.lines
271
- self._rg_file_list_time = now
272
- else:
273
- self._rg_file_list = []
274
- self._rg_file_list_time = now
275
- # Filter by keyword
276
- all_files = self._rg_file_list or []
277
- kn = key_norm
278
- results = [p for p in all_files if kn in p.lower()]
279
- # For rg fallback, we don't distinguish ignored files (no priority sorting)
280
- else:
281
- 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
+ "--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._last_ignored_paths = ignored_paths
289
- return self._filter_and_format(results, cwd, key_norm, ignored_paths)
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. Non-gitignored files first (is_ignored: 0 or 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
- ignored_paths = ignored_paths or set()
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
- # Use path directly since it's already relative to current directory
311
- rel_to_cwd = p.lstrip("./")
312
- base = os.path.basename(p).lower()
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], set[str]]:
375
- """Run fd search and return (all_results, ignored_paths).
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
- First runs fd without --no-ignore to get tracked files,
378
- then runs with --no-ignore to get all files including gitignored ones.
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
- pattern = self._escape_regex(keyword_norm)
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(self._max_results * 3),
455
+ str(max_results),
394
456
  "--exclude",
395
457
  ".git",
396
458
  "--exclude",
397
459
  ".venv",
398
460
  "--exclude",
399
461
  "node_modules",
400
- pattern,
462
+ keyword_norm,
401
463
  ".",
402
464
  ]
403
465
 
404
- # First run: get tracked (non-ignored) files
405
- r_tracked = self._run_cmd(base_cmd, cwd=cwd)
406
- tracked_paths: set[str] = set(p.lstrip("./") for p in r_tracked.lines) if r_tracked.ok else set()
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
- # Second run: get all files including ignored ones
409
- cmd_all = base_cmd.copy()
410
- cmd_all.insert(2, "--no-ignore") # Insert after --color=never
411
- r_all = self._run_cmd(cmd_all, cwd=cwd)
412
- all_paths = r_all.lines if r_all.ok else []
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
- # Calculate which paths are gitignored (in all but not in tracked)
415
- ignored_paths = set(p.lstrip("./") for p in all_paths) - tracked_paths
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
- return all_paths, ignored_paths
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
- def _escape_regex(self, s: str) -> str:
420
- # Escape for fd (regex by default). Keep '/' as is for path boundaries.
421
- return re.escape(s).replace("/", "/")
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=1.5,
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, interval: float, flush_handler: Callable[[StreamState], Awaitable[None]]):
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
- interval=1 / const.UI_REFRESH_RATE_FPS, flush_handler=self._flush_assistant_buffer
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.debouncer.flush()
284
- self.assistant_stream.debouncer.cancel()
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.debouncer.schedule()
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.debouncer.schedule()
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" / f"{project}" / "input_history.txt"
47
+ history_path = Path.home() / ".klaude" / "projects" / project / "input" / "input_history.txt"
48
48
 
49
- if not history_path.parent.exists():
50
- history_path.parent.mkdir(parents=True, exist_ok=True)
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([("#ansiblue", toolbar_text)])
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
- 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
@@ -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