klaude-code 2.8.1__py3-none-any.whl → 2.9.0__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 (93) hide show
  1. klaude_code/app/runtime.py +2 -1
  2. klaude_code/auth/antigravity/oauth.py +0 -9
  3. klaude_code/auth/antigravity/token_manager.py +0 -18
  4. klaude_code/auth/base.py +53 -0
  5. klaude_code/auth/codex/exceptions.py +0 -4
  6. klaude_code/auth/codex/oauth.py +32 -28
  7. klaude_code/auth/codex/token_manager.py +0 -18
  8. klaude_code/cli/cost_cmd.py +128 -39
  9. klaude_code/cli/list_model.py +27 -10
  10. klaude_code/cli/main.py +14 -3
  11. klaude_code/config/assets/builtin_config.yaml +8 -24
  12. klaude_code/config/config.py +47 -25
  13. klaude_code/config/sub_agent_model_helper.py +18 -13
  14. klaude_code/config/thinking.py +0 -8
  15. klaude_code/const.py +1 -1
  16. klaude_code/core/agent_profile.py +10 -52
  17. klaude_code/core/compaction/overflow.py +0 -4
  18. klaude_code/core/executor.py +33 -5
  19. klaude_code/core/manager/llm_clients.py +9 -1
  20. klaude_code/core/prompts/prompt-claude-code.md +4 -4
  21. klaude_code/core/reminders.py +21 -23
  22. klaude_code/core/task.py +0 -4
  23. klaude_code/core/tool/__init__.py +3 -2
  24. klaude_code/core/tool/file/apply_patch.py +0 -27
  25. klaude_code/core/tool/file/read_tool.md +3 -2
  26. klaude_code/core/tool/file/read_tool.py +15 -2
  27. klaude_code/core/tool/offload.py +0 -35
  28. klaude_code/core/tool/sub_agent/__init__.py +6 -0
  29. klaude_code/core/tool/sub_agent/image_gen.md +16 -0
  30. klaude_code/core/tool/sub_agent/image_gen.py +146 -0
  31. klaude_code/core/tool/sub_agent/task.md +20 -0
  32. klaude_code/core/tool/sub_agent/task.py +205 -0
  33. klaude_code/core/tool/tool_registry.py +0 -16
  34. klaude_code/core/turn.py +1 -1
  35. klaude_code/llm/anthropic/input.py +6 -5
  36. klaude_code/llm/antigravity/input.py +14 -7
  37. klaude_code/llm/codex/client.py +22 -0
  38. klaude_code/llm/codex/prompt_sync.py +237 -0
  39. klaude_code/llm/google/client.py +8 -6
  40. klaude_code/llm/google/input.py +20 -12
  41. klaude_code/llm/image.py +18 -11
  42. klaude_code/llm/input_common.py +14 -6
  43. klaude_code/llm/json_stable.py +37 -0
  44. klaude_code/llm/openai_compatible/input.py +0 -10
  45. klaude_code/llm/openai_compatible/stream.py +16 -1
  46. klaude_code/llm/registry.py +0 -5
  47. klaude_code/llm/responses/input.py +15 -5
  48. klaude_code/llm/usage.py +0 -8
  49. klaude_code/protocol/events.py +2 -1
  50. klaude_code/protocol/message.py +2 -2
  51. klaude_code/protocol/model.py +20 -1
  52. klaude_code/protocol/op.py +13 -0
  53. klaude_code/protocol/op_handler.py +5 -0
  54. klaude_code/protocol/sub_agent/AGENTS.md +5 -5
  55. klaude_code/protocol/sub_agent/__init__.py +13 -34
  56. klaude_code/protocol/sub_agent/explore.py +7 -34
  57. klaude_code/protocol/sub_agent/image_gen.py +3 -74
  58. klaude_code/protocol/sub_agent/task.py +3 -47
  59. klaude_code/protocol/sub_agent/web.py +8 -52
  60. klaude_code/protocol/tools.py +2 -0
  61. klaude_code/session/session.py +58 -21
  62. klaude_code/session/store.py +0 -4
  63. klaude_code/skill/assets/deslop/SKILL.md +9 -0
  64. klaude_code/skill/system_skills.py +0 -20
  65. klaude_code/tui/command/fork_session_cmd.py +5 -2
  66. klaude_code/tui/command/resume_cmd.py +9 -2
  67. klaude_code/tui/command/sub_agent_model_cmd.py +85 -18
  68. klaude_code/tui/components/assistant.py +0 -26
  69. klaude_code/tui/components/command_output.py +3 -1
  70. klaude_code/tui/components/developer.py +3 -0
  71. klaude_code/tui/components/diffs.py +2 -208
  72. klaude_code/tui/components/errors.py +4 -0
  73. klaude_code/tui/components/mermaid_viewer.py +2 -2
  74. klaude_code/tui/components/rich/markdown.py +0 -54
  75. klaude_code/tui/components/rich/theme.py +2 -0
  76. klaude_code/tui/components/sub_agent.py +2 -46
  77. klaude_code/tui/components/thinking.py +0 -33
  78. klaude_code/tui/components/tools.py +43 -21
  79. klaude_code/tui/input/images.py +21 -18
  80. klaude_code/tui/input/key_bindings.py +2 -2
  81. klaude_code/tui/input/prompt_toolkit.py +49 -49
  82. klaude_code/tui/machine.py +15 -11
  83. klaude_code/tui/renderer.py +11 -20
  84. klaude_code/tui/runner.py +2 -1
  85. klaude_code/tui/terminal/image.py +6 -34
  86. klaude_code/ui/common.py +0 -70
  87. {klaude_code-2.8.1.dist-info → klaude_code-2.9.0.dist-info}/METADATA +3 -6
  88. {klaude_code-2.8.1.dist-info → klaude_code-2.9.0.dist-info}/RECORD +90 -86
  89. klaude_code/core/tool/sub_agent_tool.py +0 -126
  90. klaude_code/llm/openai_compatible/tool_call_accumulator.py +0 -108
  91. klaude_code/tui/components/rich/searchable_text.py +0 -68
  92. {klaude_code-2.8.1.dist-info → klaude_code-2.9.0.dist-info}/WHEEL +0 -0
  93. {klaude_code-2.8.1.dist-info → klaude_code-2.9.0.dist-info}/entry_points.txt +0 -0
@@ -9,6 +9,8 @@ def render_error(error_msg: Text) -> RenderableType:
9
9
  """Render error with X mark for error events."""
10
10
  grid = create_grid()
11
11
  error_msg.style = ThemeKey.ERROR
12
+ error_msg.overflow = "ellipsis"
13
+ error_msg.no_wrap = True
12
14
  grid.add_row(Text("✘", style=ThemeKey.ERROR_BOLD), error_msg)
13
15
  return grid
14
16
 
@@ -17,5 +19,7 @@ def render_tool_error(error_msg: Text) -> RenderableType:
17
19
  """Render error with indent for tool results."""
18
20
  grid = create_grid()
19
21
  error_msg.style = ThemeKey.ERROR
22
+ error_msg.overflow = "ellipsis"
23
+ error_msg.no_wrap = True
20
24
  grid.add_row(Text(" "), error_msg)
21
25
  return grid
@@ -16,7 +16,7 @@ _MERMAID_DEFAULT_PNG_SCALE = 2
16
16
 
17
17
 
18
18
  def artifacts_dir() -> Path:
19
- return Path(TOOL_OUTPUT_TRUNCATION_DIR) / "mermaid"
19
+ return Path(TOOL_OUTPUT_TRUNCATION_DIR)
20
20
 
21
21
 
22
22
  def _extract_pako_from_link(link: str) -> str | None:
@@ -72,7 +72,7 @@ def ensure_viewer_file(*, code: str, link: str, tool_call_id: str) -> Path | Non
72
72
  return None
73
73
 
74
74
  safe_id = tool_call_id.replace("/", "_")
75
- path = artifacts_dir() / f"mermaid-viewer-{safe_id}.html"
75
+ path = artifacts_dir() / f"klaude-mermaid-{safe_id}.html"
76
76
  if path.exists():
77
77
  return path
78
78
 
@@ -332,11 +332,6 @@ class MarkdownStream:
332
332
  self.right_margin: int = max(right_margin, 0)
333
333
  self.markdown_class: Callable[..., Markdown] = markdown_class or NoInsetMarkdown
334
334
 
335
- @property
336
- def _live_started(self) -> bool:
337
- """Check if Live display has been started (derived from self.live)."""
338
- return self._live_sink is not None
339
-
340
335
  def _get_base_width(self) -> int:
341
336
  return self.console.options.max_width
342
337
 
@@ -450,14 +445,6 @@ class MarkdownStream:
450
445
  return "", text, 0
451
446
  return stable_source, live_source, stable_line
452
447
 
453
- def render_ansi(self, text: str, *, apply_mark: bool) -> str:
454
- """Render markdown source to an ANSI string.
455
-
456
- This is primarily intended for internal debugging and tests.
457
- """
458
- lines, _ = self._render_markdown_to_lines(text, apply_mark=apply_mark)
459
- return "".join(lines)
460
-
461
448
  def render_stable_ansi(self, stable_source: str, *, has_live_suffix: bool, final: bool) -> tuple[str, list[str]]:
462
449
  """Render stable prefix to ANSI, preserving inter-block spacing.
463
450
 
@@ -474,47 +461,6 @@ class MarkdownStream:
474
461
  lines, images = self._render_markdown_to_lines(render_source, apply_mark=True)
475
462
  return "".join(lines), images
476
463
 
477
- @staticmethod
478
- def normalize_live_ansi_for_boundary(*, stable_ansi: str, live_ansi: str) -> str:
479
- """Normalize whitespace at the stable/live boundary.
480
-
481
- Some Rich Markdown blocks (e.g. lists) render with a leading blank line.
482
- If the stable prefix already renders a trailing blank line, rendering the
483
- live suffix separately may introduce an extra blank line that wouldn't
484
- appear when rendering the full document.
485
-
486
- This function removes *overlapping* blank lines from the live ANSI when
487
- the stable ANSI already ends with one or more blank lines.
488
-
489
- Important: don't remove *all* leading blank lines from the live suffix.
490
- In some incomplete-block cases, the live render may begin with multiple
491
- blank lines while the full-document render would keep one of them.
492
- """
493
-
494
- stable_lines = stable_ansi.splitlines(keepends=True)
495
- if not stable_lines:
496
- return live_ansi
497
-
498
- stable_trailing_blank = 0
499
- for line in reversed(stable_lines):
500
- if line.strip():
501
- break
502
- stable_trailing_blank += 1
503
- if stable_trailing_blank <= 0:
504
- return live_ansi
505
-
506
- live_lines = live_ansi.splitlines(keepends=True)
507
- live_leading_blank = 0
508
- for line in live_lines:
509
- if line.strip():
510
- break
511
- live_leading_blank += 1
512
-
513
- drop = min(stable_trailing_blank, live_leading_blank)
514
- if drop > 0:
515
- live_lines = live_lines[drop:]
516
- return "".join(live_lines)
517
-
518
464
  def _append_nonfinal_sentinel(self, stable_source: str) -> str:
519
465
  """Make Rich render stable content as if it isn't the last block.
520
466
 
@@ -109,6 +109,7 @@ DARK_PALETTE = Palette(
109
109
 
110
110
  class ThemeKey(str, Enum):
111
111
  LINES = "lines"
112
+ LINES_DIM = "lines.dim"
112
113
 
113
114
  # CODE
114
115
  CODE_BACKGROUND = "code_background"
@@ -233,6 +234,7 @@ def get_theme(theme: str | None = None) -> Themes:
233
234
  app_theme=Theme(
234
235
  styles={
235
236
  ThemeKey.LINES.value: palette.grey3,
237
+ ThemeKey.LINES_DIM.value: "dim " + palette.grey3,
236
238
  # CODE
237
239
  ThemeKey.CODE_BACKGROUND.value: f"on {palette.code_background}",
238
240
  # PANEL
@@ -1,5 +1,5 @@
1
1
  import json
2
- from typing import Any, cast
2
+ from typing import Any
3
3
 
4
4
  from rich.console import Group, RenderableType
5
5
  from rich.json import JSON
@@ -7,8 +7,7 @@ from rich.style import Style
7
7
  from rich.text import Text
8
8
 
9
9
  from klaude_code.const import SUB_AGENT_RESULT_MAX_LINES
10
- from klaude_code.protocol import events, model
11
- from klaude_code.protocol.sub_agent import get_sub_agent_profile_by_tool
10
+ from klaude_code.protocol import model
12
11
  from klaude_code.tui.components.common import truncate_head
13
12
  from klaude_code.tui.components.rich.theme import ThemeKey
14
13
 
@@ -125,46 +124,3 @@ def render_sub_agent_result(
125
124
  elements.append(Text(agent_id_footer, style=ThemeKey.SUB_AGENT_FOOTER))
126
125
 
127
126
  return Group(*elements)
128
-
129
-
130
- def build_sub_agent_state_from_tool_call(e: events.ToolCallEvent) -> model.SubAgentState | None:
131
- """Build SubAgentState from a tool call event for replay rendering."""
132
- profile = get_sub_agent_profile_by_tool(e.tool_name)
133
- if profile is None:
134
- return None
135
- description = profile.name
136
- prompt = ""
137
- output_schema: dict[str, Any] | None = None
138
- generation: dict[str, Any] | None = None
139
- resume: str | None = None
140
- if e.arguments:
141
- try:
142
- payload: dict[str, object] = json.loads(e.arguments)
143
- except json.JSONDecodeError:
144
- payload = {}
145
- desc_value = payload.get("description")
146
- if isinstance(desc_value, str) and desc_value.strip():
147
- description = desc_value.strip()
148
- prompt_value = payload.get("prompt") or payload.get("task")
149
- if isinstance(prompt_value, str):
150
- prompt = prompt_value.strip()
151
- resume_value = payload.get("resume")
152
- if isinstance(resume_value, str) and resume_value.strip():
153
- resume = resume_value.strip()
154
- # Extract output_schema if profile supports it
155
- if profile.output_schema_arg:
156
- schema_value = payload.get(profile.output_schema_arg)
157
- if isinstance(schema_value, dict):
158
- output_schema = cast(dict[str, Any], schema_value)
159
- # Extract generation config for ImageGen
160
- generation_value = payload.get("generation")
161
- if isinstance(generation_value, dict):
162
- generation = cast(dict[str, Any], generation_value)
163
- return model.SubAgentState(
164
- sub_agent_type=profile.name,
165
- sub_agent_desc=description,
166
- sub_agent_prompt=prompt,
167
- resume=resume,
168
- output_schema=output_schema,
169
- generation=generation,
170
- )
@@ -1,14 +1,5 @@
1
1
  import re
2
2
 
3
- from rich.console import RenderableType
4
- from rich.padding import Padding
5
- from rich.text import Text
6
-
7
- from klaude_code.const import MARKDOWN_RIGHT_MARGIN
8
- from klaude_code.tui.components.common import create_grid
9
- from klaude_code.tui.components.rich.markdown import ThinkingMarkdown
10
- from klaude_code.tui.components.rich.theme import ThemeKey
11
-
12
3
  # UI markers
13
4
  THINKING_MESSAGE_MARK = "∴"
14
5
 
@@ -70,27 +61,3 @@ def extract_last_bold_header(text: str) -> str | None:
70
61
  i = end + 2
71
62
 
72
63
  return last
73
-
74
-
75
- def render_thinking(content: str, *, code_theme: str, style: str) -> RenderableType | None:
76
- """Render thinking content as markdown with left mark.
77
-
78
- Returns None if content is empty.
79
- Note: Caller should push thinking_markdown_theme before printing.
80
- """
81
- if len(content.strip()) == 0:
82
- return None
83
-
84
- grid = create_grid()
85
- grid.add_row(
86
- Text(THINKING_MESSAGE_MARK, style=ThemeKey.THINKING),
87
- Padding(
88
- ThinkingMarkdown(
89
- normalize_thinking_content(content),
90
- code_theme=code_theme,
91
- style=style,
92
- ),
93
- (0, MARKDOWN_RIGHT_MARGIN, 0, 0),
94
- ),
95
- )
96
- return grid
@@ -48,6 +48,33 @@ def is_sub_agent_tool(tool_name: str) -> bool:
48
48
  return _is_sub_agent_tool(tool_name)
49
49
 
50
50
 
51
+ def get_task_active_form(arguments: str) -> str:
52
+ """Return active form text for Task tool based on its arguments."""
53
+ import json
54
+
55
+ try:
56
+ parsed = json.loads(arguments)
57
+ except json.JSONDecodeError:
58
+ return "Tasking"
59
+
60
+ if not isinstance(parsed, dict):
61
+ return "Tasking"
62
+
63
+ args = cast(dict[str, Any], parsed)
64
+
65
+ type_raw = args.get("type")
66
+ if not isinstance(type_raw, str):
67
+ return "Tasking"
68
+
69
+ match type_raw.strip():
70
+ case "explore":
71
+ return "Exploring"
72
+ case "web":
73
+ return "Surfing"
74
+ case _:
75
+ return "Tasking"
76
+
77
+
51
78
  def render_path(path: str, style: str, is_directory: bool = False) -> Text:
52
79
  if path.startswith(str(Path().cwd())):
53
80
  path = path.replace(str(Path().cwd()), "").lstrip("/")
@@ -173,7 +200,7 @@ def render_bash_tool_call(arguments: str) -> RenderableType:
173
200
 
174
201
 
175
202
  def render_update_plan_tool_call(arguments: str) -> RenderableType:
176
- tool_name = "Update Plan"
203
+ tool_name = "Plan"
177
204
  details: RenderableType | None = None
178
205
 
179
206
  if arguments:
@@ -273,7 +300,7 @@ def render_write_tool_call(arguments: str) -> RenderableType:
273
300
 
274
301
 
275
302
  def render_apply_patch_tool_call(arguments: str) -> RenderableType:
276
- tool_name = "Apply Patch"
303
+ tool_name = "Patch"
277
304
 
278
305
  try:
279
306
  payload = json.loads(arguments)
@@ -299,21 +326,22 @@ def render_apply_patch_tool_call(arguments: str) -> RenderableType:
299
326
  elif line.startswith("*** Delete File:"):
300
327
  delete_files.append(line[len("*** Delete File:") :].strip())
301
328
 
302
- parts: list[str] = []
329
+ details = Text("", ThemeKey.TOOL_PARAM)
303
330
  if update_files:
304
- parts.append(f"Update File × {len(update_files)}" if len(update_files) > 1 else "Update File")
331
+ details.append(f"Edit × {len(update_files)}")
305
332
  if add_files:
333
+ if details.plain:
334
+ details.append(", ")
306
335
  # For single .md file addition, show filename in parentheses
307
336
  if len(add_files) == 1 and add_files[0].endswith(".md"):
308
- file_name = Path(add_files[0]).name
309
- parts.append(f"Add File ({file_name})")
337
+ details.append("Create ")
338
+ details.append_text(render_path(add_files[0], ThemeKey.TOOL_PARAM_FILE_PATH))
310
339
  else:
311
- parts.append(f"Add File × {len(add_files)}" if len(add_files) > 1 else "Add File")
340
+ details.append(f"Create × {len(add_files)}")
312
341
  if delete_files:
313
- parts.append(f"Delete File × {len(delete_files)}" if len(delete_files) > 1 else "Delete File")
314
-
315
- if parts:
316
- details = Text(", ".join(parts), ThemeKey.TOOL_PARAM)
342
+ if details.plain:
343
+ details.append(", ")
344
+ details.append(f"Delete × {len(delete_files)}")
317
345
  else:
318
346
  details = Text(
319
347
  str(patch_content)[:INVALID_TOOL_CALL_MAX_LENGTH],
@@ -434,7 +462,7 @@ def _render_mermaid_viewer_link(
434
462
 
435
463
 
436
464
  def render_web_fetch_tool_call(arguments: str) -> RenderableType:
437
- tool_name = "Fetch"
465
+ tool_name = "Fetch Web"
438
466
 
439
467
  try:
440
468
  payload: dict[str, str] = json.loads(arguments)
@@ -452,7 +480,7 @@ def render_web_fetch_tool_call(arguments: str) -> RenderableType:
452
480
 
453
481
 
454
482
  def render_web_search_tool_call(arguments: str) -> RenderableType:
455
- tool_name = "Web Search"
483
+ tool_name = "Search Web"
456
484
 
457
485
  try:
458
486
  payload: dict[str, Any] = json.loads(arguments)
@@ -516,6 +544,7 @@ _TOOL_ACTIVE_FORM: dict[str, str] = {
516
544
  tools.WEB_FETCH: "Fetching Web",
517
545
  tools.WEB_SEARCH: "Searching Web",
518
546
  tools.REPORT_BACK: "Reporting",
547
+ tools.IMAGE_GEN: "Generating Image",
519
548
  }
520
549
 
521
550
 
@@ -527,13 +556,6 @@ def get_tool_active_form(tool_name: str) -> str:
527
556
  if tool_name in _TOOL_ACTIVE_FORM:
528
557
  return _TOOL_ACTIVE_FORM[tool_name]
529
558
 
530
- # Check sub agent profiles
531
- from klaude_code.protocol.sub_agent import get_sub_agent_profile_by_tool
532
-
533
- profile = get_sub_agent_profile_by_tool(tool_name)
534
- if profile and profile.active_form:
535
- return profile.active_form
536
-
537
559
  return f"Calling {tool_name}"
538
560
 
539
561
 
@@ -558,7 +580,7 @@ def render_tool_call(e: events.ToolCallEvent) -> RenderableType | None:
558
580
  case tools.APPLY_PATCH:
559
581
  return render_apply_patch_tool_call(e.arguments)
560
582
  case tools.TODO_WRITE:
561
- return render_generic_tool_call("Update Todos", "", MARK_PLAN)
583
+ return render_generic_tool_call("Update To-Dos", "", MARK_PLAN)
562
584
  case tools.UPDATE_PLAN:
563
585
  return render_update_plan_tool_call(e.arguments)
564
586
  case tools.MERMAID:
@@ -17,11 +17,10 @@ import shutil
17
17
  import subprocess
18
18
  import sys
19
19
  import uuid
20
- from base64 import b64encode
21
20
  from pathlib import Path
22
21
 
23
22
  from klaude_code.const import get_system_temp
24
- from klaude_code.protocol.message import ImageURLPart
23
+ from klaude_code.protocol.message import ImageFilePart
25
24
 
26
25
  # ---------------------------------------------------------------------------
27
26
  # Constants and marker syntax
@@ -183,36 +182,40 @@ def capture_clipboard_tag() -> str | None:
183
182
  # ---------------------------------------------------------------------------
184
183
 
185
184
 
186
- def _encode_image_file(file_path: str) -> ImageURLPart | None:
187
- """Encode an image file as base64 data URL and create ImageURLPart."""
185
+ _MIME_TYPES: dict[str, str] = {
186
+ ".png": "image/png",
187
+ ".jpg": "image/jpeg",
188
+ ".jpeg": "image/jpeg",
189
+ ".gif": "image/gif",
190
+ ".webp": "image/webp",
191
+ }
192
+
193
+
194
+ def _create_image_file_part(file_path: str) -> ImageFilePart | None:
195
+ """Create an ImageFilePart from a file path."""
188
196
  try:
189
197
  path = Path(file_path)
190
198
  if not path.exists():
191
199
  return None
192
- with open(path, "rb") as f:
193
- encoded = b64encode(f.read()).decode("ascii")
194
200
 
195
201
  suffix = path.suffix.lower()
196
- mime = {
197
- ".png": "image/png",
198
- ".jpg": "image/jpeg",
199
- ".jpeg": "image/jpeg",
200
- ".gif": "image/gif",
201
- ".webp": "image/webp",
202
- }.get(suffix)
202
+ mime = _MIME_TYPES.get(suffix)
203
203
  if mime is None:
204
204
  return None
205
205
 
206
- data_url = f"data:{mime};base64,{encoded}"
207
- return ImageURLPart(url=data_url, id=None)
206
+ return ImageFilePart(
207
+ file_path=str(path),
208
+ mime_type=mime,
209
+ byte_size=path.stat().st_size,
210
+ )
208
211
  except OSError:
209
212
  return None
210
213
 
211
214
 
212
- def extract_images_from_text(text: str) -> list[ImageURLPart]:
215
+ def extract_images_from_text(text: str) -> list[ImageFilePart]:
213
216
  """Extract images referenced by [image ...] markers in text."""
214
217
 
215
- images: list[ImageURLPart] = []
218
+ images: list[ImageFilePart] = []
216
219
  for m in IMAGE_MARKER_RE.finditer(text):
217
220
  raw = m.group("path")
218
221
  path_str = parse_image_marker_path(raw)
@@ -221,7 +224,7 @@ def extract_images_from_text(text: str) -> list[ImageURLPart]:
221
224
  p = Path(path_str).expanduser()
222
225
  if not p.is_absolute():
223
226
  p = (Path.cwd() / p).resolve()
224
- image_part = _encode_image_file(str(p))
227
+ image_part = _create_image_file_part(str(p))
225
228
  if image_part:
226
229
  images.append(image_part)
227
230
  return images
@@ -19,7 +19,7 @@ from typing import cast
19
19
  from prompt_toolkit.application.current import get_app
20
20
  from prompt_toolkit.buffer import Buffer
21
21
  from prompt_toolkit.filters import Always, Condition, Filter
22
- from prompt_toolkit.filters.app import has_completions
22
+ from prompt_toolkit.filters.app import has_completions, is_searching
23
23
  from prompt_toolkit.key_binding import KeyBindings
24
24
  from prompt_toolkit.key_binding.key_processor import KeyPressEvent
25
25
  from prompt_toolkit.keys import Keys
@@ -367,7 +367,7 @@ def create_key_bindings(
367
367
 
368
368
  _insert_newline(event)
369
369
 
370
- @kb.add("enter", filter=enabled)
370
+ @kb.add("enter", filter=enabled & ~is_searching)
371
371
  def _(event: KeyPressEvent) -> None:
372
372
  nonlocal swallow_next_control_j
373
373
 
@@ -20,6 +20,7 @@ from prompt_toolkit.key_binding import merge_key_bindings
20
20
  from prompt_toolkit.layout import Float
21
21
  from prompt_toolkit.layout.containers import Container, FloatContainer, Window
22
22
  from prompt_toolkit.layout.controls import BufferControl, UIContent
23
+ from prompt_toolkit.layout.dimension import Dimension
23
24
  from prompt_toolkit.layout.menus import CompletionsMenu, MultiColumnCompletionsMenu
24
25
  from prompt_toolkit.patch_stdout import patch_stdout
25
26
  from prompt_toolkit.styles import Style
@@ -61,9 +62,9 @@ COMPLETION_SELECTED_LIGHT_BG = "ansigreen"
61
62
  COMPLETION_SELECTED_UNKNOWN_BG = "ansigreen"
62
63
  COMPLETION_MENU = "ansibrightblack"
63
64
  INPUT_PROMPT_STYLE = "ansimagenta bold"
64
- PLACEHOLDER_TEXT_STYLE_DARK_BG = "fg:#5a5a5a italic"
65
- PLACEHOLDER_TEXT_STYLE_LIGHT_BG = "fg:#7a7a7a italic"
66
- PLACEHOLDER_TEXT_STYLE_UNKNOWN_BG = "fg:#8a8a8a italic"
65
+ PLACEHOLDER_TEXT_STYLE_DARK_BG = "fg:#5a5a5a"
66
+ PLACEHOLDER_TEXT_STYLE_LIGHT_BG = "fg:#7a7a7a"
67
+ PLACEHOLDER_TEXT_STYLE_UNKNOWN_BG = "fg:#8a8a8a"
67
68
  PLACEHOLDER_SYMBOL_STYLE_DARK_BG = "bg:#2a2a2a fg:#5a5a5a"
68
69
  PLACEHOLDER_SYMBOL_STYLE_LIGHT_BG = "bg:#e6e6e6 fg:#7a7a7a"
69
70
  PLACEHOLDER_SYMBOL_STYLE_UNKNOWN_BG = "bg:#2a2a2a fg:#8a8a8a"
@@ -81,6 +82,9 @@ def _left_align_completion_menus(container: Container) -> None:
81
82
  cursor (`xcursor=True`). That makes the popup indent as the caret moves.
82
83
  We walk the layout tree and rewrite the Float positioning for completion menus
83
84
  to keep them fixed at the left edge.
85
+
86
+ Note: We intentionally keep Y positioning (ycursor) unchanged so that the
87
+ completion menu stays near the cursor/input line.
84
88
  """
85
89
  if isinstance(container, FloatContainer):
86
90
  for flt in container.floats:
@@ -300,6 +304,10 @@ class PromptToolkitInput(InputProviderABC):
300
304
  key_bindings=kb,
301
305
  completer=ThreadedCompleter(create_repl_completer(command_info_provider=self._command_info_provider)),
302
306
  complete_while_typing=True,
307
+ # Keep the bottom toolbar stable while completion menus open/close.
308
+ # Reserving space dynamically can make the non-fullscreen prompt
309
+ # "jump" by printing extra lines.
310
+ reserve_space_for_menu=0,
303
311
  erase_when_done=True,
304
312
  mouse_support=False,
305
313
  style=Style.from_dict(
@@ -417,41 +425,40 @@ class PromptToolkitInput(InputProviderABC):
417
425
 
418
426
  original_height = input_window.height
419
427
 
428
+ # Keep a comfortable multiline editing area even when no completion
429
+ # space is reserved. (We set reserve_space_for_menu=0 to avoid the
430
+ # bottom toolbar jumping when completions open/close.)
431
+ base_rows = 10
432
+
420
433
  def _height(): # type: ignore[no-untyped-def]
421
434
  picker_open = (self._model_picker is not None and self._model_picker.is_open) or (
422
435
  self._thinking_picker is not None and self._thinking_picker.is_open
423
436
  )
424
437
 
425
- try:
426
- complete_state = self._session.default_buffer.complete_state
427
- completion_open = complete_state is not None and bool(complete_state.completions)
428
- except Exception:
429
- completion_open = False
430
-
431
438
  try:
432
439
  original_height_value = original_height() if callable(original_height) else original_height
433
440
  except Exception:
434
441
  original_height_value = None
435
- original_height_int = original_height_value if isinstance(original_height_value, int) else None
442
+ original_min = 0
443
+ if isinstance(original_height_value, Dimension):
444
+ original_min = int(original_height_value.min)
445
+ elif isinstance(original_height_value, int):
446
+ original_min = int(original_height_value)
436
447
 
437
- if picker_open or completion_open:
438
- target_rows = 24 if picker_open else 14
448
+ target_rows = 24 if picker_open else base_rows
439
449
 
440
- # Cap to the current terminal size.
441
- # Leave a small buffer to avoid triggering "Window too small".
442
- try:
443
- rows = get_app().output.get_size().rows
444
- except Exception:
445
- rows = 0
450
+ # Cap to the current terminal size.
451
+ # Leave a small buffer to avoid triggering "Window too small".
452
+ try:
453
+ rows = get_app().output.get_size().rows
454
+ except Exception:
455
+ rows = 0
446
456
 
447
- expanded = max(3, min(target_rows, rows - 2))
448
- if original_height_int is not None:
449
- expanded = max(original_height_int, expanded)
450
- return expanded
457
+ desired = max(original_min, target_rows)
458
+ if rows > 0:
459
+ desired = max(3, min(desired, rows - 2))
451
460
 
452
- if callable(original_height):
453
- return original_height()
454
- return original_height
461
+ return Dimension(min=desired, preferred=desired)
455
462
 
456
463
  input_window.height = _height
457
464
 
@@ -583,7 +590,7 @@ class PromptToolkitInput(InputProviderABC):
583
590
  except (AttributeError, RuntimeError):
584
591
  pass
585
592
 
586
- # Priority: update_message > debug_log_path
593
+ # Priority: update_message > debug_log_path > shortcut hints
587
594
  display_text: str | None = None
588
595
  text_style: str = ""
589
596
  if update_message:
@@ -593,31 +600,25 @@ class PromptToolkitInput(InputProviderABC):
593
600
  display_text = f"Debug log: {debug_log_path}"
594
601
  text_style = "fg:ansibrightblack"
595
602
 
596
- # If nothing to show, return a blank line to actively clear any previously
597
- # rendered content. (When `bottom_toolbar` is a callable, prompt_toolkit
598
- # will still reserve the toolbar line.)
599
- if not display_text:
603
+ if display_text:
604
+ left_text = " " + display_text
600
605
  try:
601
606
  terminal_width = shutil.get_terminal_size().columns
607
+ padding = " " * max(0, terminal_width - len(left_text))
602
608
  except (OSError, ValueError):
603
- terminal_width = 0
604
- return FormattedText([("", " " * max(0, terminal_width))])
609
+ padding = ""
605
610
 
606
- left_text = " " + display_text
607
- try:
608
- terminal_width = shutil.get_terminal_size().columns
609
- padding = " " * max(0, terminal_width - len(left_text))
610
- except (OSError, ValueError):
611
- padding = ""
611
+ toolbar_text = left_text + padding
612
+ return FormattedText([(text_style, toolbar_text)])
612
613
 
613
- toolbar_text = left_text + padding
614
- return FormattedText([(text_style, toolbar_text)])
614
+ # Show shortcut hints when nothing else to display
615
+ return self._render_shortcut_hints()
615
616
 
616
617
  # -------------------------------------------------------------------------
617
- # Placeholder
618
+ # Shortcut hints (bottom toolbar)
618
619
  # -------------------------------------------------------------------------
619
620
 
620
- def _render_input_placeholder(self) -> FormattedText:
621
+ def _render_shortcut_hints(self) -> FormattedText:
621
622
  if self._is_light_terminal_background is True:
622
623
  text_style = PLACEHOLDER_TEXT_STYLE_LIGHT_BG
623
624
  symbol_style = PLACEHOLDER_SYMBOL_STYLE_LIGHT_BG
@@ -630,27 +631,27 @@ class PromptToolkitInput(InputProviderABC):
630
631
 
631
632
  return FormattedText(
632
633
  [
633
- (text_style, " " * 10),
634
+ (text_style, " "),
634
635
  (symbol_style, " @ "),
635
636
  (text_style, " "),
636
637
  (text_style, "files"),
637
- (text_style, " "),
638
+ (text_style, ""),
638
639
  (symbol_style, " $ "),
639
640
  (text_style, " "),
640
641
  (text_style, "skills"),
641
- (text_style, " "),
642
+ (text_style, ""),
642
643
  (symbol_style, " / "),
643
644
  (text_style, " "),
644
645
  (text_style, "commands"),
645
- (text_style, " "),
646
+ (text_style, ""),
646
647
  (symbol_style, " ctrl-l "),
647
648
  (text_style, " "),
648
649
  (text_style, "models"),
649
- (text_style, " "),
650
+ (text_style, ""),
650
651
  (symbol_style, " ctrl-t "),
651
652
  (text_style, " "),
652
653
  (text_style, "think"),
653
- (text_style, " "),
654
+ (text_style, ""),
654
655
  (symbol_style, " ctrl-v "),
655
656
  (text_style, " "),
656
657
  (text_style, "paste image"),
@@ -679,7 +680,6 @@ class PromptToolkitInput(InputProviderABC):
679
680
  # proper styling instead of showing raw escape codes.
680
681
  with patch_stdout(raw=True):
681
682
  line: str = await self._session.prompt_async(
682
- placeholder=self._render_input_placeholder(),
683
683
  bottom_toolbar=self._get_bottom_toolbar,
684
684
  )
685
685
  if self._post_prompt is not None: