klaude-code 2.8.1__py3-none-any.whl → 2.9.1__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 (107) hide show
  1. klaude_code/app/runtime.py +2 -1
  2. klaude_code/auth/antigravity/oauth.py +33 -38
  3. klaude_code/auth/antigravity/token_manager.py +0 -18
  4. klaude_code/auth/base.py +53 -0
  5. klaude_code/auth/claude/oauth.py +34 -49
  6. klaude_code/auth/codex/exceptions.py +0 -4
  7. klaude_code/auth/codex/oauth.py +32 -28
  8. klaude_code/auth/codex/token_manager.py +0 -18
  9. klaude_code/cli/cost_cmd.py +128 -39
  10. klaude_code/cli/list_model.py +27 -10
  11. klaude_code/cli/main.py +14 -3
  12. klaude_code/config/assets/builtin_config.yaml +25 -24
  13. klaude_code/config/config.py +47 -25
  14. klaude_code/config/sub_agent_model_helper.py +18 -13
  15. klaude_code/config/thinking.py +0 -8
  16. klaude_code/const.py +1 -1
  17. klaude_code/core/agent_profile.py +11 -56
  18. klaude_code/core/compaction/overflow.py +0 -4
  19. klaude_code/core/executor.py +33 -5
  20. klaude_code/core/manager/llm_clients.py +9 -1
  21. klaude_code/core/prompts/prompt-claude-code.md +4 -4
  22. klaude_code/core/reminders.py +21 -23
  23. klaude_code/core/task.py +1 -5
  24. klaude_code/core/tool/__init__.py +3 -2
  25. klaude_code/core/tool/file/apply_patch.py +0 -27
  26. klaude_code/core/tool/file/read_tool.md +3 -2
  27. klaude_code/core/tool/file/read_tool.py +27 -3
  28. klaude_code/core/tool/offload.py +0 -35
  29. klaude_code/core/tool/shell/bash_tool.py +1 -1
  30. klaude_code/core/tool/sub_agent/__init__.py +6 -0
  31. klaude_code/core/tool/sub_agent/image_gen.md +16 -0
  32. klaude_code/core/tool/sub_agent/image_gen.py +146 -0
  33. klaude_code/core/tool/sub_agent/task.md +20 -0
  34. klaude_code/core/tool/sub_agent/task.py +205 -0
  35. klaude_code/core/tool/tool_registry.py +0 -16
  36. klaude_code/core/turn.py +1 -1
  37. klaude_code/llm/anthropic/input.py +6 -5
  38. klaude_code/llm/antigravity/input.py +14 -7
  39. klaude_code/llm/bedrock_anthropic/__init__.py +3 -0
  40. klaude_code/llm/google/client.py +8 -6
  41. klaude_code/llm/google/input.py +20 -12
  42. klaude_code/llm/image.py +18 -11
  43. klaude_code/llm/input_common.py +32 -6
  44. klaude_code/llm/json_stable.py +37 -0
  45. klaude_code/llm/{codex → openai_codex}/__init__.py +1 -1
  46. klaude_code/llm/{codex → openai_codex}/client.py +24 -2
  47. klaude_code/llm/openai_codex/prompt_sync.py +237 -0
  48. klaude_code/llm/openai_compatible/client.py +3 -1
  49. klaude_code/llm/openai_compatible/input.py +0 -10
  50. klaude_code/llm/openai_compatible/stream.py +35 -10
  51. klaude_code/llm/{responses → openai_responses}/client.py +1 -1
  52. klaude_code/llm/{responses → openai_responses}/input.py +15 -5
  53. klaude_code/llm/registry.py +3 -8
  54. klaude_code/llm/stream_parts.py +3 -1
  55. klaude_code/llm/usage.py +1 -9
  56. klaude_code/protocol/events.py +2 -2
  57. klaude_code/protocol/message.py +3 -2
  58. klaude_code/protocol/model.py +34 -2
  59. klaude_code/protocol/op.py +13 -0
  60. klaude_code/protocol/op_handler.py +5 -0
  61. klaude_code/protocol/sub_agent/AGENTS.md +5 -5
  62. klaude_code/protocol/sub_agent/__init__.py +13 -34
  63. klaude_code/protocol/sub_agent/explore.py +7 -34
  64. klaude_code/protocol/sub_agent/image_gen.py +3 -74
  65. klaude_code/protocol/sub_agent/task.py +3 -47
  66. klaude_code/protocol/sub_agent/web.py +8 -52
  67. klaude_code/protocol/tools.py +2 -0
  68. klaude_code/session/session.py +80 -22
  69. klaude_code/session/store.py +0 -4
  70. klaude_code/skill/assets/deslop/SKILL.md +9 -0
  71. klaude_code/skill/system_skills.py +0 -20
  72. klaude_code/tui/command/fork_session_cmd.py +5 -2
  73. klaude_code/tui/command/resume_cmd.py +9 -2
  74. klaude_code/tui/command/sub_agent_model_cmd.py +85 -18
  75. klaude_code/tui/components/assistant.py +0 -26
  76. klaude_code/tui/components/bash_syntax.py +4 -0
  77. klaude_code/tui/components/command_output.py +3 -1
  78. klaude_code/tui/components/developer.py +3 -0
  79. klaude_code/tui/components/diffs.py +4 -209
  80. klaude_code/tui/components/errors.py +4 -0
  81. klaude_code/tui/components/mermaid_viewer.py +2 -2
  82. klaude_code/tui/components/metadata.py +0 -3
  83. klaude_code/tui/components/rich/markdown.py +120 -87
  84. klaude_code/tui/components/rich/status.py +2 -2
  85. klaude_code/tui/components/rich/theme.py +11 -6
  86. klaude_code/tui/components/sub_agent.py +2 -46
  87. klaude_code/tui/components/thinking.py +0 -33
  88. klaude_code/tui/components/tools.py +65 -21
  89. klaude_code/tui/components/user_input.py +2 -0
  90. klaude_code/tui/input/images.py +21 -18
  91. klaude_code/tui/input/key_bindings.py +2 -2
  92. klaude_code/tui/input/prompt_toolkit.py +49 -49
  93. klaude_code/tui/machine.py +29 -47
  94. klaude_code/tui/renderer.py +48 -33
  95. klaude_code/tui/runner.py +2 -1
  96. klaude_code/tui/terminal/image.py +27 -34
  97. klaude_code/ui/common.py +0 -70
  98. {klaude_code-2.8.1.dist-info → klaude_code-2.9.1.dist-info}/METADATA +3 -6
  99. {klaude_code-2.8.1.dist-info → klaude_code-2.9.1.dist-info}/RECORD +103 -99
  100. klaude_code/core/tool/sub_agent_tool.py +0 -126
  101. klaude_code/llm/bedrock/__init__.py +0 -3
  102. klaude_code/llm/openai_compatible/tool_call_accumulator.py +0 -108
  103. klaude_code/tui/components/rich/searchable_text.py +0 -68
  104. /klaude_code/llm/{bedrock → bedrock_anthropic}/client.py +0 -0
  105. /klaude_code/llm/{responses → openai_responses}/__init__.py +0 -0
  106. {klaude_code-2.8.1.dist-info → klaude_code-2.9.1.dist-info}/WHEEL +0 -0
  107. {klaude_code-2.8.1.dist-info → klaude_code-2.9.1.dist-info}/entry_points.txt +0 -0
@@ -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
@@ -10,8 +10,10 @@ from rich.text import Text
10
10
 
11
11
  from klaude_code.const import (
12
12
  BASH_OUTPUT_PANEL_THRESHOLD,
13
+ DIFF_PREFIX_WIDTH,
13
14
  INVALID_TOOL_CALL_MAX_LENGTH,
14
15
  QUERY_DISPLAY_TRUNCATE_LENGTH,
16
+ TAB_EXPAND_WIDTH,
15
17
  URL_TRUNCATE_MAX_LENGTH,
16
18
  WEB_SEARCH_DEFAULT_MAX_RESULTS,
17
19
  )
@@ -48,6 +50,33 @@ def is_sub_agent_tool(tool_name: str) -> bool:
48
50
  return _is_sub_agent_tool(tool_name)
49
51
 
50
52
 
53
+ def get_task_active_form(arguments: str) -> str:
54
+ """Return active form text for Task tool based on its arguments."""
55
+ import json
56
+
57
+ try:
58
+ parsed = json.loads(arguments)
59
+ except json.JSONDecodeError:
60
+ return "Tasking"
61
+
62
+ if not isinstance(parsed, dict):
63
+ return "Tasking"
64
+
65
+ args = cast(dict[str, Any], parsed)
66
+
67
+ type_raw = args.get("type")
68
+ if not isinstance(type_raw, str):
69
+ return "Tasking"
70
+
71
+ match type_raw.strip():
72
+ case "explore":
73
+ return "Exploring"
74
+ case "web":
75
+ return "Surfing"
76
+ case _:
77
+ return "Tasking"
78
+
79
+
51
80
  def render_path(path: str, style: str, is_directory: bool = False) -> Text:
52
81
  if path.startswith(str(Path().cwd())):
53
82
  path = path.replace(str(Path().cwd()), "").lstrip("/")
@@ -173,7 +202,7 @@ def render_bash_tool_call(arguments: str) -> RenderableType:
173
202
 
174
203
 
175
204
  def render_update_plan_tool_call(arguments: str) -> RenderableType:
176
- tool_name = "Update Plan"
205
+ tool_name = "Plan"
177
206
  details: RenderableType | None = None
178
207
 
179
208
  if arguments:
@@ -273,7 +302,7 @@ def render_write_tool_call(arguments: str) -> RenderableType:
273
302
 
274
303
 
275
304
  def render_apply_patch_tool_call(arguments: str) -> RenderableType:
276
- tool_name = "Apply Patch"
305
+ tool_name = "Patch"
277
306
 
278
307
  try:
279
308
  payload = json.loads(arguments)
@@ -299,21 +328,22 @@ def render_apply_patch_tool_call(arguments: str) -> RenderableType:
299
328
  elif line.startswith("*** Delete File:"):
300
329
  delete_files.append(line[len("*** Delete File:") :].strip())
301
330
 
302
- parts: list[str] = []
331
+ details = Text("", ThemeKey.TOOL_PARAM)
303
332
  if update_files:
304
- parts.append(f"Update File × {len(update_files)}" if len(update_files) > 1 else "Update File")
333
+ details.append(f"Edit × {len(update_files)}")
305
334
  if add_files:
335
+ if details.plain:
336
+ details.append(", ")
306
337
  # For single .md file addition, show filename in parentheses
307
338
  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})")
339
+ details.append("Create ")
340
+ details.append_text(render_path(add_files[0], ThemeKey.TOOL_PARAM_FILE_PATH))
310
341
  else:
311
- parts.append(f"Add File × {len(add_files)}" if len(add_files) > 1 else "Add File")
342
+ details.append(f"Create × {len(add_files)}")
312
343
  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)
344
+ if details.plain:
345
+ details.append(", ")
346
+ details.append(f"Delete × {len(delete_files)}")
317
347
  else:
318
348
  details = Text(
319
349
  str(patch_content)[:INVALID_TOOL_CALL_MAX_LENGTH],
@@ -359,6 +389,24 @@ def render_generic_tool_result(result: str, *, is_error: bool = False) -> Render
359
389
  return text
360
390
 
361
391
 
392
+ def render_read_preview(ui_extra: model.ReadPreviewUIExtra) -> RenderableType:
393
+ """Render read preview with line numbers aligned to diff style."""
394
+ grid = create_grid()
395
+ grid.padding = (0, 0)
396
+
397
+ for line in ui_extra.lines:
398
+ prefix = f"{line.line_no:>{DIFF_PREFIX_WIDTH}} "
399
+ content = line.content.expandtabs(TAB_EXPAND_WIDTH)
400
+ grid.add_row(Text(prefix, ThemeKey.TOOL_RESULT), Text(content, ThemeKey.TOOL_RESULT))
401
+
402
+ if ui_extra.remaining_lines > 0:
403
+ remaining_prefix = f"{'⋮':>{DIFF_PREFIX_WIDTH}} "
404
+ remaining_text = Text(f"(more {ui_extra.remaining_lines} lines)", ThemeKey.TOOL_RESULT_TRUNCATED)
405
+ grid.add_row(Text(remaining_prefix, ThemeKey.TOOL_RESULT_TRUNCATED), remaining_text)
406
+
407
+ return grid
408
+
409
+
362
410
  def _extract_mermaid_link(
363
411
  ui_extra: model.ToolResultUIExtra | None,
364
412
  ) -> model.MermaidLinkUIExtra | None:
@@ -434,7 +482,7 @@ def _render_mermaid_viewer_link(
434
482
 
435
483
 
436
484
  def render_web_fetch_tool_call(arguments: str) -> RenderableType:
437
- tool_name = "Fetch"
485
+ tool_name = "Fetch Web"
438
486
 
439
487
  try:
440
488
  payload: dict[str, str] = json.loads(arguments)
@@ -452,7 +500,7 @@ def render_web_fetch_tool_call(arguments: str) -> RenderableType:
452
500
 
453
501
 
454
502
  def render_web_search_tool_call(arguments: str) -> RenderableType:
455
- tool_name = "Web Search"
503
+ tool_name = "Search Web"
456
504
 
457
505
  try:
458
506
  payload: dict[str, Any] = json.loads(arguments)
@@ -516,6 +564,7 @@ _TOOL_ACTIVE_FORM: dict[str, str] = {
516
564
  tools.WEB_FETCH: "Fetching Web",
517
565
  tools.WEB_SEARCH: "Searching Web",
518
566
  tools.REPORT_BACK: "Reporting",
567
+ tools.IMAGE_GEN: "Generating Image",
519
568
  }
520
569
 
521
570
 
@@ -527,13 +576,6 @@ def get_tool_active_form(tool_name: str) -> str:
527
576
  if tool_name in _TOOL_ACTIVE_FORM:
528
577
  return _TOOL_ACTIVE_FORM[tool_name]
529
578
 
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
579
  return f"Calling {tool_name}"
538
580
 
539
581
 
@@ -558,7 +600,7 @@ def render_tool_call(e: events.ToolCallEvent) -> RenderableType | None:
558
600
  case tools.APPLY_PATCH:
559
601
  return render_apply_patch_tool_call(e.arguments)
560
602
  case tools.TODO_WRITE:
561
- return render_generic_tool_call("Update Todos", "", MARK_PLAN)
603
+ return render_generic_tool_call("Update To-Dos", "", MARK_PLAN)
562
604
  case tools.UPDATE_PLAN:
563
605
  return render_update_plan_tool_call(e.arguments)
564
606
  case tools.MERMAID:
@@ -656,6 +698,8 @@ def render_tool_result(
656
698
 
657
699
  match e.tool_name:
658
700
  case tools.READ:
701
+ if isinstance(e.ui_extra, model.ReadPreviewUIExtra):
702
+ return wrap(render_read_preview(e.ui_extra))
659
703
  return None
660
704
  case tools.EDIT:
661
705
  return wrap(r_diffs.render_structured_diff(diff_ui) if diff_ui else Text(""))
@@ -3,6 +3,7 @@ import re
3
3
  from rich.console import Group, RenderableType
4
4
  from rich.text import Text
5
5
 
6
+ from klaude_code.const import TAB_EXPAND_WIDTH
6
7
  from klaude_code.skill import get_available_skills
7
8
  from klaude_code.tui.components.common import create_grid
8
9
  from klaude_code.tui.components.rich.theme import ThemeKey
@@ -82,6 +83,7 @@ def render_user_input(content: str) -> RenderableType:
82
83
  lines = content.strip().split("\n")
83
84
  renderables: list[RenderableType] = []
84
85
  for i, line in enumerate(lines):
86
+ line = line.expandtabs(TAB_EXPAND_WIDTH)
85
87
  # Handle slash command on first line
86
88
  if i == 0 and line.startswith("/"):
87
89
  splits = line.split(" ", maxsplit=1)
@@ -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: