klaude-code 2.10.2__py3-none-any.whl → 2.10.4__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 (63) hide show
  1. klaude_code/auth/AGENTS.md +4 -24
  2. klaude_code/auth/__init__.py +1 -17
  3. klaude_code/cli/auth_cmd.py +3 -53
  4. klaude_code/cli/list_model.py +0 -50
  5. klaude_code/config/assets/builtin_config.yaml +7 -35
  6. klaude_code/config/config.py +5 -42
  7. klaude_code/const.py +5 -2
  8. klaude_code/core/agent_profile.py +2 -10
  9. klaude_code/core/backtrack/__init__.py +3 -0
  10. klaude_code/core/backtrack/manager.py +48 -0
  11. klaude_code/core/memory.py +25 -9
  12. klaude_code/core/task.py +53 -7
  13. klaude_code/core/tool/__init__.py +2 -0
  14. klaude_code/core/tool/backtrack/__init__.py +3 -0
  15. klaude_code/core/tool/backtrack/backtrack_tool.md +17 -0
  16. klaude_code/core/tool/backtrack/backtrack_tool.py +65 -0
  17. klaude_code/core/tool/context.py +5 -0
  18. klaude_code/core/turn.py +3 -0
  19. klaude_code/llm/anthropic/input.py +28 -4
  20. klaude_code/llm/input_common.py +70 -1
  21. klaude_code/llm/openai_compatible/input.py +5 -2
  22. klaude_code/llm/openrouter/input.py +5 -2
  23. klaude_code/llm/registry.py +0 -1
  24. klaude_code/protocol/events.py +10 -0
  25. klaude_code/protocol/llm_param.py +0 -1
  26. klaude_code/protocol/message.py +10 -1
  27. klaude_code/protocol/tools.py +1 -0
  28. klaude_code/session/session.py +111 -2
  29. klaude_code/session/store.py +2 -0
  30. klaude_code/skill/assets/executing-plans/SKILL.md +84 -0
  31. klaude_code/skill/assets/writing-plans/SKILL.md +116 -0
  32. klaude_code/tui/commands.py +15 -0
  33. klaude_code/tui/components/developer.py +1 -1
  34. klaude_code/tui/components/errors.py +2 -4
  35. klaude_code/tui/components/metadata.py +5 -10
  36. klaude_code/tui/components/rich/markdown.py +5 -1
  37. klaude_code/tui/components/rich/status.py +7 -76
  38. klaude_code/tui/components/rich/theme.py +12 -2
  39. klaude_code/tui/components/tools.py +31 -18
  40. klaude_code/tui/components/user_input.py +1 -1
  41. klaude_code/tui/display.py +4 -0
  42. klaude_code/tui/input/completers.py +51 -17
  43. klaude_code/tui/input/images.py +127 -0
  44. klaude_code/tui/input/prompt_toolkit.py +16 -2
  45. klaude_code/tui/machine.py +26 -8
  46. klaude_code/tui/renderer.py +97 -0
  47. klaude_code/tui/runner.py +7 -2
  48. klaude_code/tui/terminal/image.py +28 -12
  49. klaude_code/ui/terminal/title.py +8 -3
  50. {klaude_code-2.10.2.dist-info → klaude_code-2.10.4.dist-info}/METADATA +1 -1
  51. {klaude_code-2.10.2.dist-info → klaude_code-2.10.4.dist-info}/RECORD +53 -56
  52. klaude_code/auth/antigravity/__init__.py +0 -20
  53. klaude_code/auth/antigravity/exceptions.py +0 -17
  54. klaude_code/auth/antigravity/oauth.py +0 -315
  55. klaude_code/auth/antigravity/pkce.py +0 -25
  56. klaude_code/auth/antigravity/token_manager.py +0 -27
  57. klaude_code/core/prompts/prompt-antigravity.md +0 -80
  58. klaude_code/llm/antigravity/__init__.py +0 -3
  59. klaude_code/llm/antigravity/client.py +0 -558
  60. klaude_code/llm/antigravity/input.py +0 -268
  61. klaude_code/skill/assets/create-plan/SKILL.md +0 -74
  62. {klaude_code-2.10.2.dist-info → klaude_code-2.10.4.dist-info}/WHEEL +0 -0
  63. {klaude_code-2.10.2.dist-info → klaude_code-2.10.4.dist-info}/entry_points.txt +0 -0
@@ -10,7 +10,6 @@ from rich.style import Style
10
10
  from rich.text import Text
11
11
 
12
12
  from klaude_code.const import (
13
- BASH_OUTPUT_PANEL_THRESHOLD,
14
13
  DIFF_PREFIX_WIDTH,
15
14
  INVALID_TOOL_CALL_MAX_LENGTH,
16
15
  QUERY_DISPLAY_TRUNCATE_LENGTH,
@@ -24,7 +23,6 @@ from klaude_code.tui.components import diffs as r_diffs
24
23
  from klaude_code.tui.components import mermaid_viewer as r_mermaid_viewer
25
24
  from klaude_code.tui.components.bash_syntax import highlight_bash_command
26
25
  from klaude_code.tui.components.common import create_grid, truncate_middle
27
- from klaude_code.tui.components.rich.code_panel import CodePanel
28
26
  from klaude_code.tui.components.rich.markdown import NoInsetMarkdown
29
27
  from klaude_code.tui.components.rich.quote import TreeQuote
30
28
  from klaude_code.tui.components.rich.theme import ThemeKey
@@ -40,6 +38,7 @@ MARK_MERMAID = "⧉"
40
38
  MARK_WEB_FETCH = "→"
41
39
  MARK_WEB_SEARCH = "✱"
42
40
  MARK_DONE = "✔"
41
+ MARK_BACKTRACK = "↶"
43
42
 
44
43
  # Todo status markers
45
44
  MARK_TODO_PENDING = "▢"
@@ -168,22 +167,6 @@ def render_bash_tool_call(arguments: str) -> RenderableType:
168
167
  cmd_str = command.strip()
169
168
  highlighted = highlight_bash_command(cmd_str)
170
169
 
171
- display_line_count = len(highlighted.plain.splitlines())
172
-
173
- if display_line_count > BASH_OUTPUT_PANEL_THRESHOLD:
174
- code_panel = CodePanel(highlighted, border_style=ThemeKey.LINES)
175
- if isinstance(timeout_ms, int):
176
- if timeout_ms >= 1000 and timeout_ms % 1000 == 0:
177
- timeout_text = Text(f"{timeout_ms // 1000}s", style=ThemeKey.TOOL_TIMEOUT)
178
- else:
179
- timeout_text = Text(f"{timeout_ms}ms", style=ThemeKey.TOOL_TIMEOUT)
180
- return _render_tool_call_tree(
181
- mark=MARK_BASH,
182
- tool_name=tool_name,
183
- details=Group(code_panel, timeout_text),
184
- )
185
- else:
186
- return _render_tool_call_tree(mark=MARK_BASH, tool_name=tool_name, details=code_panel)
187
170
  if isinstance(timeout_ms, int):
188
171
  if timeout_ms >= 1000 and timeout_ms % 1000 == 0:
189
172
  highlighted.append(f" {timeout_ms // 1000}s", style=ThemeKey.TOOL_TIMEOUT)
@@ -552,6 +535,33 @@ def render_report_back_tool_call() -> RenderableType:
552
535
  return _render_tool_call_tree(mark=MARK_DONE, tool_name="Report Back", details=None)
553
536
 
554
537
 
538
+ def render_backtrack_tool_call(arguments: str) -> RenderableType:
539
+ tool_name = "Backtrack"
540
+
541
+ try:
542
+ payload: dict[str, Any] = json.loads(arguments)
543
+ except json.JSONDecodeError:
544
+ details = Text(
545
+ arguments.strip()[:INVALID_TOOL_CALL_MAX_LENGTH],
546
+ style=ThemeKey.INVALID_TOOL_CALL_ARGS,
547
+ )
548
+ return _render_tool_call_tree(mark=MARK_BACKTRACK, tool_name=tool_name, details=details)
549
+
550
+ checkpoint_id = payload.get("checkpoint_id")
551
+ rationale = payload.get("rationale", "")
552
+
553
+ summary = Text("", ThemeKey.TOOL_PARAM)
554
+ if isinstance(checkpoint_id, int):
555
+ summary.append(f"Checkpoint {checkpoint_id}", ThemeKey.TOOL_PARAM_BOLD)
556
+ if rationale:
557
+ rationale_preview = rationale if len(rationale) <= 50 else rationale[:47] + "..."
558
+ if summary.plain:
559
+ summary.append(" - ")
560
+ summary.append(rationale_preview, ThemeKey.TOOL_PARAM)
561
+
562
+ return _render_tool_call_tree(mark=MARK_BACKTRACK, tool_name=tool_name, details=summary if summary.plain else None)
563
+
564
+
555
565
  # Tool name to active form mapping (for spinner status)
556
566
  _TOOL_ACTIVE_FORM: dict[str, str] = {
557
567
  tools.BASH: "Bashing",
@@ -567,6 +577,7 @@ _TOOL_ACTIVE_FORM: dict[str, str] = {
567
577
  tools.REPORT_BACK: "Reporting",
568
578
  tools.IMAGE_GEN: "Generating Image",
569
579
  tools.TASK: "Spawning Task",
580
+ tools.BACKTRACK: "Backtracking",
570
581
  }
571
582
 
572
583
 
@@ -609,6 +620,8 @@ def render_tool_call(e: events.ToolCallEvent) -> RenderableType | None:
609
620
  return render_mermaid_tool_call(e.arguments)
610
621
  case tools.REPORT_BACK:
611
622
  return render_report_back_tool_call()
623
+ case tools.BACKTRACK:
624
+ return render_backtrack_tool_call(e.arguments)
612
625
  case tools.WEB_FETCH:
613
626
  return render_web_fetch_tool_call(e.arguments)
614
627
  case tools.WEB_SEARCH:
@@ -89,7 +89,7 @@ def render_user_input(content: str) -> RenderableType:
89
89
 
90
90
  return Padding(
91
91
  Group(*renderables),
92
- pad=(0, 1),
92
+ pad=(1, 1),
93
93
  style=ThemeKey.USER_INPUT,
94
94
  expand=False,
95
95
  )
@@ -101,3 +101,7 @@ class TUIDisplay(DisplayABC):
101
101
  self._renderer.spinner_stop()
102
102
  with contextlib.suppress(Exception):
103
103
  self._renderer.stop_bottom_live()
104
+
105
+ def set_model_name(self, model_name: str | None) -> None:
106
+ """Set model name for terminal title updates."""
107
+ self._machine.set_model_name(model_name)
@@ -673,34 +673,68 @@ class _AtFilesCompleter(Completer):
673
673
  all_files_lower = self._git_file_list_lower or []
674
674
  kn = keyword_norm
675
675
 
676
- # Bound per-keystroke work: stop scanning once enough matches are found.
676
+ # Bound per-keystroke work.
677
+ #
678
+ # Important: When the keyword is common (e.g. "tools"), truncating the
679
+ # scan purely by number of matching *files* can accidentally hide valid
680
+ # directory completions that appear later in the git path order.
681
+ #
682
+ # Example: multiple */tools/ directories under different parents.
683
+ file_quota = max_results
684
+ dir_quota = max_results
685
+ scan_cap = max(2000, max_results * 200)
686
+
687
+ keyword_stripped = keyword_norm.strip("/")
688
+ keyword_basename = os.path.basename(keyword_stripped)
689
+ explicit_parent = "/" in keyword_stripped
690
+
691
+ def dir_matches_keyword(dir_path: str) -> bool:
692
+ if not keyword_basename:
693
+ return False
694
+ if explicit_parent:
695
+ # When user typed an explicit parent segment, match against the
696
+ # whole directory path (not just basename).
697
+ return kn in f"{dir_path}/".lower()
698
+ # Otherwise prioritize directories by basename match.
699
+ return keyword_basename in os.path.basename(dir_path).lower()
700
+
677
701
  matching_files: list[str] = []
702
+ dir_list: list[str] = []
703
+ dir_seen: set[str] = set()
678
704
  scan_truncated = False
705
+ scanned = 0
706
+
679
707
  for p, pl in zip(all_files, all_files_lower, strict=False):
680
- if kn in pl:
681
- matching_files.append(p)
682
- if len(matching_files) >= max_results:
708
+ scanned += 1
709
+ if kn not in pl:
710
+ if scanned >= scan_cap and (matching_files or dir_list):
683
711
  scan_truncated = True
684
712
  break
713
+ continue
714
+
715
+ if len(matching_files) < file_quota:
716
+ matching_files.append(p)
685
717
 
686
- # Also include parent directories of matching files so users can
687
- # complete into a folder, similar to fd's directory results.
688
- dir_candidates: set[str] = set()
689
- for p in matching_files[: max_results * 3]:
718
+ # Collect matching parent directories, walking upwards until repo root.
719
+ # This allows completing into directories like "image/tools/" even
720
+ # when the matching file is nested deeper.
690
721
  parent = os.path.dirname(p)
691
722
  while parent and parent != ".":
692
- dir_candidates.add(f"{parent}/")
723
+ if dir_matches_keyword(parent):
724
+ cand = f"{parent}/"
725
+ if cand not in dir_seen:
726
+ dir_seen.add(cand)
727
+ dir_list.append(cand)
728
+ if len(dir_list) >= dir_quota:
729
+ break
693
730
  parent = os.path.dirname(parent)
694
731
 
695
- dir_list = sorted(dir_candidates)
696
- dir_truncated = False
697
- if len(dir_list) > max_results:
698
- dir_list = dir_list[:max_results]
699
- dir_truncated = True
732
+ if len(matching_files) >= file_quota and len(dir_list) >= dir_quota:
733
+ scan_truncated = True
734
+ break
700
735
 
701
- candidates = matching_files + dir_list
702
- truncated = scan_truncated or dir_truncated
703
- return candidates, truncated
736
+ candidates = dir_list + matching_files
737
+ return candidates, scan_truncated
704
738
 
705
739
  def _get_git_repo_root(self, cwd: Path) -> Path | None:
706
740
  if not self._has_cmd("git"):
@@ -28,6 +28,9 @@ from klaude_code.protocol.message import ImageFilePart
28
28
 
29
29
  IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"})
30
30
 
31
+ # Claude API limit is 5MB, we use 4.5MB to have some margin
32
+ MAX_IMAGE_SIZE_BYTES = 4_500_000
33
+
31
34
  IMAGE_MARKER_RE = re.compile(r'\[image (?P<path>"[^"]+"|[^\]]+)\]')
32
35
 
33
36
 
@@ -159,6 +162,127 @@ def _grab_clipboard_image(dest_path: Path) -> bool:
159
162
  return _grab_clipboard_image_linux(dest_path)
160
163
 
161
164
 
165
+ # ---------------------------------------------------------------------------
166
+ # Image resizing for size limits
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ def _get_image_dimensions_macos(path: Path) -> tuple[int, int] | None:
171
+ """Get image dimensions using sips on macOS."""
172
+ try:
173
+ result = subprocess.run(
174
+ ["sips", "-g", "pixelWidth", "-g", "pixelHeight", str(path)],
175
+ capture_output=True,
176
+ text=True,
177
+ )
178
+ if result.returncode != 0:
179
+ return None
180
+ width = height = 0
181
+ for line in result.stdout.splitlines():
182
+ if "pixelWidth" in line:
183
+ width = int(line.split(":")[-1].strip())
184
+ elif "pixelHeight" in line:
185
+ height = int(line.split(":")[-1].strip())
186
+ if width > 0 and height > 0:
187
+ return (width, height)
188
+ except (OSError, ValueError):
189
+ pass
190
+ return None
191
+
192
+
193
+ def _resize_image_macos(path: Path, scale: float) -> bool:
194
+ """Resize image using sips on macOS. Modifies file in place."""
195
+ dims = _get_image_dimensions_macos(path)
196
+ if dims is None:
197
+ return False
198
+ new_width = max(1, int(dims[0] * scale))
199
+ try:
200
+ result = subprocess.run(
201
+ ["sips", "--resampleWidth", str(new_width), str(path)],
202
+ capture_output=True,
203
+ )
204
+ return result.returncode == 0
205
+ except OSError:
206
+ return False
207
+
208
+
209
+ def _resize_image_linux(path: Path, scale: float) -> bool:
210
+ """Resize image using ImageMagick convert on Linux."""
211
+ if not shutil.which("convert"):
212
+ return False
213
+ percent = int(scale * 100)
214
+ try:
215
+ result = subprocess.run(
216
+ ["convert", str(path), "-resize", f"{percent}%", str(path)],
217
+ capture_output=True,
218
+ )
219
+ return result.returncode == 0
220
+ except OSError:
221
+ return False
222
+
223
+
224
+ def _resize_image_windows(path: Path, scale: float) -> bool:
225
+ """Resize image using PowerShell on Windows."""
226
+ script = f'''
227
+ Add-Type -AssemblyName System.Drawing
228
+ $img = [System.Drawing.Image]::FromFile("{path}")
229
+ $newWidth = [int]($img.Width * {scale})
230
+ $newHeight = [int]($img.Height * {scale})
231
+ $bmp = New-Object System.Drawing.Bitmap($newWidth, $newHeight)
232
+ $graphics = [System.Drawing.Graphics]::FromImage($bmp)
233
+ $graphics.DrawImage($img, 0, 0, $newWidth, $newHeight)
234
+ $img.Dispose()
235
+ $bmp.Save("{path}", [System.Drawing.Imaging.ImageFormat]::Png)
236
+ $bmp.Dispose()
237
+ $graphics.Dispose()
238
+ Write-Output "ok"
239
+ '''
240
+ try:
241
+ result = subprocess.run(
242
+ ["powershell", "-Command", script],
243
+ capture_output=True,
244
+ text=True,
245
+ )
246
+ return result.returncode == 0 and "ok" in result.stdout
247
+ except OSError:
248
+ return False
249
+
250
+
251
+ def _resize_image(path: Path, scale: float) -> bool:
252
+ """Resize image by scale factor. Modifies file in place."""
253
+ if sys.platform == "darwin":
254
+ return _resize_image_macos(path, scale)
255
+ elif sys.platform == "win32":
256
+ return _resize_image_windows(path, scale)
257
+ else:
258
+ return _resize_image_linux(path, scale)
259
+
260
+
261
+ def _ensure_image_size_limit(path: Path, max_bytes: int = MAX_IMAGE_SIZE_BYTES) -> None:
262
+ """Resize image if it exceeds the size limit. Modifies file in place."""
263
+ try:
264
+ current_size = path.stat().st_size
265
+ if current_size <= max_bytes:
266
+ return
267
+
268
+ # Calculate scale factor based on size ratio
269
+ # We use sqrt because area scales with square of linear dimensions
270
+ scale = (max_bytes / current_size) ** 0.5
271
+ # Be a bit more aggressive to ensure we get under the limit
272
+ scale *= 0.9
273
+
274
+ for _ in range(5): # Max 5 resize attempts
275
+ if not _resize_image(path, scale):
276
+ return
277
+ new_size = path.stat().st_size
278
+ if new_size <= max_bytes:
279
+ return
280
+ # Still too large, reduce more
281
+ scale = 0.8
282
+ except OSError:
283
+ pass
284
+
285
+
162
286
  def capture_clipboard_tag() -> str | None:
163
287
  """Capture an image from clipboard and return an [image ...] marker."""
164
288
 
@@ -174,6 +298,9 @@ def capture_clipboard_tag() -> str | None:
174
298
  if not _grab_clipboard_image(path):
175
299
  return None
176
300
 
301
+ # Resize if image exceeds size limit
302
+ _ensure_image_size_limit(path)
303
+
177
304
  return format_image_marker(str(path))
178
305
 
179
306
 
@@ -61,7 +61,7 @@ COMPLETION_SELECTED_DARK_BG = "ansigreen"
61
61
  COMPLETION_SELECTED_LIGHT_BG = "ansigreen"
62
62
  COMPLETION_SELECTED_UNKNOWN_BG = "ansigreen"
63
63
  COMPLETION_MENU = "ansibrightblack"
64
- INPUT_PROMPT_STYLE = "ansimagenta bold"
64
+ INPUT_PROMPT_STYLE = "ansicyan bold"
65
65
  INPUT_PROMPT_BASH_STYLE = "ansigreen bold"
66
66
  PLACEHOLDER_TEXT_STYLE_DARK_BG = "fg:#5a5a5a"
67
67
  PLACEHOLDER_TEXT_STYLE_LIGHT_BG = "fg:#7a7a7a"
@@ -448,6 +448,9 @@ class PromptToolkitInput(InputProviderABC):
448
448
  # Keep a comfortable multiline editing area even when no completion
449
449
  # space is reserved. (We set reserve_space_for_menu=0 to avoid the
450
450
  # bottom toolbar jumping when completions open/close.)
451
+ #
452
+ # Also allow the input area to grow with content so that large multi-line
453
+ # inputs expand the prompt instead of scrolling within a fixed-height window.
451
454
  base_rows = 10
452
455
 
453
456
  def _height(): # type: ignore[no-untyped-def]
@@ -465,7 +468,18 @@ class PromptToolkitInput(InputProviderABC):
465
468
  elif isinstance(original_height_value, int):
466
469
  original_min = int(original_height_value)
467
470
 
468
- target_rows = 24 if picker_open else base_rows
471
+ try:
472
+ buffer_line_count = int(self._session.default_buffer.document.line_count)
473
+ except Exception:
474
+ buffer_line_count = 1
475
+
476
+ # Grow with content (based on newline count), but keep a sensible minimum.
477
+ content_rows = max(1, buffer_line_count)
478
+ target_rows = max(base_rows, content_rows)
479
+
480
+ # When a picker overlay is open, keep enough height for it to be usable.
481
+ if picker_open:
482
+ target_rows = max(target_rows, 24)
469
483
 
470
484
  # Cap to the current terminal size.
471
485
  # Leave a small buffer to avoid triggering "Window too small".
@@ -24,6 +24,7 @@ from klaude_code.tui.commands import (
24
24
  EndThinkingStream,
25
25
  PrintBlankLine,
26
26
  RenderAssistantImage,
27
+ RenderBacktrack,
27
28
  RenderBashCommandEnd,
28
29
  RenderBashCommandStart,
29
30
  RenderCommand,
@@ -47,6 +48,7 @@ from klaude_code.tui.commands import (
47
48
  StartThinkingStream,
48
49
  TaskClockClear,
49
50
  TaskClockStart,
51
+ UpdateTerminalTitlePrefix,
50
52
  )
51
53
  from klaude_code.tui.components.rich import status as r_status
52
54
  from klaude_code.tui.components.rich.theme import ThemeKey
@@ -65,6 +67,7 @@ FAST_TOOLS: frozenset[str] = frozenset(
65
67
  tools.UPDATE_PLAN,
66
68
  tools.APPLY_PATCH,
67
69
  tools.REPORT_BACK,
70
+ tools.BACKTRACK,
68
71
  }
69
72
  )
70
73
 
@@ -135,7 +138,7 @@ class ActivityState:
135
138
  for name, count in counts.items():
136
139
  if not first:
137
140
  activity_text.append(", ")
138
- activity_text.append(Text(name, style=ThemeKey.STATUS_TEXT_BOLD))
141
+ activity_text.append(Text(name, style=ThemeKey.STATUS_TEXT))
139
142
  if count > 1:
140
143
  activity_text.append(f" x {count}")
141
144
  first = False
@@ -238,24 +241,21 @@ class SpinnerStatusState:
238
241
 
239
242
  if extra_reasoning is not None:
240
243
  if activity_text is None:
241
- activity_text = Text(extra_reasoning, style=ThemeKey.STATUS_TEXT_BOLD_ITALIC)
244
+ activity_text = Text(extra_reasoning, style=ThemeKey.STATUS_TEXT)
242
245
  else:
243
- prefixed = Text(extra_reasoning, style=ThemeKey.STATUS_TEXT_BOLD_ITALIC)
246
+ prefixed = Text(extra_reasoning, style=ThemeKey.STATUS_TEXT)
244
247
  prefixed.append(" , ")
245
248
  prefixed.append_text(activity_text)
246
249
  activity_text = prefixed
247
250
 
248
251
  if base_status:
249
- # Default "Thinking ..." uses normal style; custom headers use bold italic
250
- is_default_reasoning = base_status in {STATUS_THINKING_TEXT, STATUS_RUNNING_TEXT}
251
- status_style = ThemeKey.STATUS_TEXT if is_default_reasoning else ThemeKey.STATUS_TEXT_BOLD_ITALIC
252
252
  if activity_text:
253
253
  result = Text()
254
- result.append(base_status, style=status_style)
254
+ result.append(base_status, style=ThemeKey.STATUS_TEXT)
255
255
  result.append(" | ")
256
256
  result.append_text(activity_text)
257
257
  else:
258
- result = Text(base_status, style=status_style)
258
+ result = Text(base_status, style=ThemeKey.STATUS_TEXT)
259
259
  elif activity_text:
260
260
  activity_text.append(" …")
261
261
  result = activity_text
@@ -323,6 +323,10 @@ class DisplayStateMachine:
323
323
  self._sessions: dict[str, _SessionState] = {}
324
324
  self._primary_session_id: str | None = None
325
325
  self._spinner = SpinnerStatusState()
326
+ self._model_name: str | None = None
327
+
328
+ def set_model_name(self, model_name: str | None) -> None:
329
+ self._model_name = model_name
326
330
 
327
331
  def _reset_sessions(self) -> None:
328
332
  self._sessions = {}
@@ -442,6 +446,7 @@ class DisplayStateMachine:
442
446
  self._primary_session_id = e.session_id
443
447
  if not is_replay:
444
448
  cmds.append(TaskClockStart())
449
+ cmds.append(UpdateTerminalTitlePrefix(prefix="\u26ac", model_name=self._model_name))
445
450
 
446
451
  if not is_replay:
447
452
  cmds.append(SpinnerStart())
@@ -469,6 +474,18 @@ class DisplayStateMachine:
469
474
  cmds.append(RenderCompactionSummary(summary=e.summary, kept_items_brief=kept_brief))
470
475
  return cmds
471
476
 
477
+ case events.BacktrackEvent() as e:
478
+ cmds.append(
479
+ RenderBacktrack(
480
+ checkpoint_id=e.checkpoint_id,
481
+ note=e.note,
482
+ rationale=e.rationale,
483
+ original_user_message=e.original_user_message,
484
+ messages_discarded=e.messages_discarded,
485
+ )
486
+ )
487
+ return cmds
488
+
472
489
  case events.DeveloperMessageEvent() as e:
473
490
  cmds.append(RenderDeveloperMessage(e))
474
491
  return cmds
@@ -747,6 +764,7 @@ class DisplayStateMachine:
747
764
  self._spinner.reset()
748
765
  cmds.append(SpinnerStop())
749
766
  cmds.append(EmitTmuxSignal())
767
+ cmds.append(UpdateTerminalTitlePrefix(prefix="\u2714", model_name=self._model_name))
750
768
  return cmds
751
769
 
752
770
  case events.InterruptEvent() as e:
@@ -35,6 +35,7 @@ from klaude_code.tui.commands import (
35
35
  PrintBlankLine,
36
36
  PrintRuleLine,
37
37
  RenderAssistantImage,
38
+ RenderBacktrack,
38
39
  RenderBashCommandEnd,
39
40
  RenderBashCommandStart,
40
41
  RenderCommand,
@@ -59,6 +60,7 @@ from klaude_code.tui.commands import (
59
60
  StartThinkingStream,
60
61
  TaskClockClear,
61
62
  TaskClockStart,
63
+ UpdateTerminalTitlePrefix,
62
64
  )
63
65
  from klaude_code.tui.components import command_output as c_command_output
64
66
  from klaude_code.tui.components import developer as c_developer
@@ -85,6 +87,7 @@ from klaude_code.tui.terminal.notifier import (
85
87
  emit_tmux_signal,
86
88
  )
87
89
  from klaude_code.tui.terminal.progress_bar import OSC94States, emit_osc94
90
+ from klaude_code.ui.terminal.title import update_terminal_title
88
91
 
89
92
 
90
93
  @dataclass
@@ -136,6 +139,7 @@ class _SessionStatus:
136
139
  color: Style | None = None
137
140
  color_index: int | None = None
138
141
  sub_agent_state: model.SubAgentState | None = None
142
+ turn_first_dev_msg_rendered: bool = False
139
143
 
140
144
 
141
145
  class TUICommandRenderer:
@@ -475,6 +479,10 @@ class TUICommandRenderer:
475
479
  if not c_developer.need_render_developer_message(e):
476
480
  return
477
481
  with self.session_print_context(e.session_id):
482
+ session_status = self._sessions.get(e.session_id)
483
+ if session_status and not session_status.turn_first_dev_msg_rendered:
484
+ session_status.turn_first_dev_msg_rendered = True
485
+ self.print()
478
486
  self.print(c_developer.render_developer_message(e))
479
487
 
480
488
  # Display images from @ file references and user attachments
@@ -567,6 +575,8 @@ class TUICommandRenderer:
567
575
  )
568
576
 
569
577
  def display_turn_start(self, event: events.TurnStartEvent) -> None:
578
+ if event.session_id in self._sessions:
579
+ self._sessions[event.session_id].turn_first_dev_msg_rendered = False
570
580
  if not self.is_sub_agent_session(event.session_id):
571
581
  self.print()
572
582
 
@@ -610,6 +620,8 @@ class TUICommandRenderer:
610
620
  sub_agent_color=self._current_sub_agent_color,
611
621
  )
612
622
  )
623
+ else:
624
+ self.print()
613
625
 
614
626
  def display_interrupt(self) -> None:
615
627
  self.print(c_user_input.render_interrupt())
@@ -678,6 +690,75 @@ class TUICommandRenderer:
678
690
 
679
691
  self.print()
680
692
 
693
+ def display_backtrack(
694
+ self,
695
+ checkpoint_id: int,
696
+ note: str,
697
+ rationale: str,
698
+ original_user_message: str,
699
+ messages_discarded: int | None,
700
+ ) -> None:
701
+ self.console.print(
702
+ Rule(
703
+ Text(f"Backtracked to Checkpoint {checkpoint_id}", style=ThemeKey.BACKTRACK),
704
+ characters="=",
705
+ style=ThemeKey.LINES,
706
+ )
707
+ )
708
+ self.print()
709
+
710
+ if messages_discarded:
711
+ self.console.print(Text(f" Discarded {messages_discarded} messages", style=ThemeKey.BACKTRACK_INFO))
712
+
713
+ if rationale:
714
+ self.console.print(Text(" Rationale:", style=ThemeKey.BACKTRACK_INFO))
715
+ rationale_preview = rationale[:300] + "..." if len(rationale) > 300 else rationale
716
+ self.console.print(
717
+ Padding(
718
+ Panel(
719
+ NoInsetMarkdown(
720
+ rationale_preview, code_theme=self.themes.code_theme, style=ThemeKey.BACKTRACK_NOTE
721
+ ),
722
+ box=box.SIMPLE,
723
+ border_style=ThemeKey.LINES,
724
+ style=ThemeKey.BACKTRACK_NOTE,
725
+ ),
726
+ (0, 0, 0, 4),
727
+ )
728
+ )
729
+
730
+ if original_user_message:
731
+ self.console.print(Text(" Returned to:", style=ThemeKey.BACKTRACK_INFO))
732
+ msg_preview = (
733
+ original_user_message[:200] + "..." if len(original_user_message) > 200 else original_user_message
734
+ )
735
+ self.console.print(
736
+ Padding(
737
+ Panel(
738
+ Text(msg_preview, style=ThemeKey.BACKTRACK_USER_MESSAGE),
739
+ box=box.SIMPLE,
740
+ border_style=ThemeKey.LINES,
741
+ ),
742
+ (0, 0, 0, 4),
743
+ )
744
+ )
745
+
746
+ self.console.print(Text(" Summary:", style=ThemeKey.BACKTRACK_INFO))
747
+ note_preview = note[:300] + "..." if len(note) > 300 else note
748
+ self.console.print(
749
+ Padding(
750
+ Panel(
751
+ NoInsetMarkdown(note_preview, code_theme=self.themes.code_theme, style=ThemeKey.BACKTRACK_NOTE),
752
+ box=box.SIMPLE,
753
+ border_style=ThemeKey.LINES,
754
+ style=ThemeKey.BACKTRACK_NOTE,
755
+ ),
756
+ (0, 0, 0, 4),
757
+ )
758
+ )
759
+
760
+ self.print()
761
+
681
762
  # ---------------------------------------------------------------------
682
763
  # Notifications
683
764
  # ---------------------------------------------------------------------
@@ -797,6 +878,20 @@ class TUICommandRenderer:
797
878
  self.display_error(event)
798
879
  case RenderCompactionSummary(summary=summary, kept_items_brief=kept_items_brief):
799
880
  self.display_compaction_summary(summary, kept_items_brief)
881
+ case RenderBacktrack(
882
+ checkpoint_id=checkpoint_id,
883
+ note=note,
884
+ rationale=rationale,
885
+ original_user_message=original_user_message,
886
+ messages_discarded=messages_discarded,
887
+ ):
888
+ self.display_backtrack(
889
+ checkpoint_id=checkpoint_id,
890
+ note=note,
891
+ rationale=rationale,
892
+ original_user_message=original_user_message,
893
+ messages_discarded=messages_discarded,
894
+ )
800
895
  case SpinnerStart():
801
896
  self.spinner_start()
802
897
  case SpinnerStop():
@@ -815,6 +910,8 @@ class TUICommandRenderer:
815
910
  r_status.set_task_start()
816
911
  case TaskClockClear():
817
912
  r_status.clear_task_start()
913
+ case UpdateTerminalTitlePrefix(prefix=prefix, model_name=model_name):
914
+ update_terminal_title(model_name, prefix=prefix)
818
915
  case _:
819
916
  continue
820
917
 
klaude_code/tui/runner.py CHANGED
@@ -142,14 +142,19 @@ async def run_interactive(init_config: AppInitConfig, session_id: str | None = N
142
142
  elif detected is False:
143
143
  theme = "dark"
144
144
 
145
- display: ui.DisplayABC = TUIDisplay(theme=theme)
145
+ tui_display = TUIDisplay(theme=theme)
146
+ display: ui.DisplayABC = tui_display
146
147
  if init_config.debug:
147
148
  display = ui.DebugEventDisplay(display)
148
149
 
150
+ def _on_model_change(model_name: str) -> None:
151
+ update_terminal_title(model_name)
152
+ tui_display.set_model_name(model_name)
153
+
149
154
  components = await initialize_app_components(
150
155
  init_config=init_config,
151
156
  display=display,
152
- on_model_change=update_terminal_title,
157
+ on_model_change=_on_model_change,
153
158
  )
154
159
 
155
160
  def _status_provider() -> REPLStatusSnapshot: