klaude-code 2.7.0__py3-none-any.whl → 2.8.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.
- klaude_code/auth/AGENTS.md +325 -0
- klaude_code/auth/__init__.py +17 -1
- klaude_code/auth/antigravity/__init__.py +20 -0
- klaude_code/auth/antigravity/exceptions.py +17 -0
- klaude_code/auth/antigravity/oauth.py +320 -0
- klaude_code/auth/antigravity/pkce.py +25 -0
- klaude_code/auth/antigravity/token_manager.py +45 -0
- klaude_code/auth/base.py +4 -0
- klaude_code/auth/claude/oauth.py +29 -9
- klaude_code/auth/codex/exceptions.py +4 -0
- klaude_code/cli/auth_cmd.py +53 -3
- klaude_code/cli/cost_cmd.py +83 -160
- klaude_code/cli/list_model.py +50 -0
- klaude_code/cli/main.py +1 -1
- klaude_code/config/assets/builtin_config.yaml +108 -0
- klaude_code/config/builtin_config.py +5 -11
- klaude_code/config/config.py +24 -10
- klaude_code/const.py +1 -0
- klaude_code/core/agent.py +5 -1
- klaude_code/core/agent_profile.py +28 -32
- klaude_code/core/compaction/AGENTS.md +112 -0
- klaude_code/core/compaction/__init__.py +11 -0
- klaude_code/core/compaction/compaction.py +707 -0
- klaude_code/core/compaction/overflow.py +30 -0
- klaude_code/core/compaction/prompts.py +97 -0
- klaude_code/core/executor.py +103 -2
- klaude_code/core/manager/llm_clients.py +5 -0
- klaude_code/core/manager/llm_clients_builder.py +14 -2
- klaude_code/core/prompts/prompt-antigravity.md +80 -0
- klaude_code/core/prompts/prompt-codex-gpt-5-2.md +335 -0
- klaude_code/core/reminders.py +7 -2
- klaude_code/core/task.py +126 -0
- klaude_code/core/tool/todo/todo_write_tool.py +1 -1
- klaude_code/core/turn.py +3 -1
- klaude_code/llm/antigravity/__init__.py +3 -0
- klaude_code/llm/antigravity/client.py +558 -0
- klaude_code/llm/antigravity/input.py +261 -0
- klaude_code/llm/registry.py +1 -0
- klaude_code/protocol/events.py +18 -0
- klaude_code/protocol/llm_param.py +1 -0
- klaude_code/protocol/message.py +23 -1
- klaude_code/protocol/op.py +15 -1
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/session/session.py +36 -0
- klaude_code/skill/assets/create-plan/SKILL.md +6 -6
- klaude_code/tui/command/__init__.py +3 -0
- klaude_code/tui/command/compact_cmd.py +32 -0
- klaude_code/tui/command/fork_session_cmd.py +110 -14
- klaude_code/tui/command/model_picker.py +5 -1
- klaude_code/tui/command/thinking_cmd.py +1 -1
- klaude_code/tui/commands.py +6 -0
- klaude_code/tui/components/rich/markdown.py +57 -1
- klaude_code/tui/components/rich/theme.py +10 -2
- klaude_code/tui/components/tools.py +39 -25
- klaude_code/tui/components/user_input.py +1 -1
- klaude_code/tui/input/__init__.py +5 -2
- klaude_code/tui/input/drag_drop.py +6 -57
- klaude_code/tui/input/key_bindings.py +10 -0
- klaude_code/tui/input/prompt_toolkit.py +19 -6
- klaude_code/tui/machine.py +25 -0
- klaude_code/tui/renderer.py +67 -4
- klaude_code/tui/runner.py +18 -2
- klaude_code/tui/terminal/image.py +72 -10
- klaude_code/tui/terminal/selector.py +31 -7
- {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/METADATA +1 -1
- {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/RECORD +68 -52
- klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +0 -117
- {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/WHEEL +0 -0
- {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/entry_points.txt +0 -0
|
@@ -28,10 +28,14 @@ FORK_SELECT_STYLE = merge_styles(
|
|
|
28
28
|
class ForkPoint:
|
|
29
29
|
"""A fork point in conversation history."""
|
|
30
30
|
|
|
31
|
+
kind: Literal["user", "compaction", "end"]
|
|
31
32
|
history_index: int # -1 means fork entire conversation
|
|
32
|
-
user_message: str
|
|
33
33
|
tool_call_stats: dict[str, int] # tool_name -> count
|
|
34
|
-
|
|
34
|
+
user_message: str = ""
|
|
35
|
+
last_assistant_summary: str = ""
|
|
36
|
+
compaction_summary_preview: str = ""
|
|
37
|
+
compaction_first_kept_index: int | None = None
|
|
38
|
+
compaction_tokens_before: int | None = None
|
|
35
39
|
|
|
36
40
|
|
|
37
41
|
def _truncate(text: str, max_len: int = 60) -> str:
|
|
@@ -42,11 +46,61 @@ def _truncate(text: str, max_len: int = 60) -> str:
|
|
|
42
46
|
return text[: max_len - 1] + "…"
|
|
43
47
|
|
|
44
48
|
|
|
49
|
+
def _first_non_empty_line(text: str) -> str:
|
|
50
|
+
for line in text.splitlines():
|
|
51
|
+
stripped = line.strip()
|
|
52
|
+
if stripped:
|
|
53
|
+
return stripped
|
|
54
|
+
return ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _preview_compaction_summary(summary: str) -> str:
|
|
58
|
+
"""Return a human-friendly preview line for a CompactionEntry summary.
|
|
59
|
+
|
|
60
|
+
Compaction summaries may start with a fixed prefix line and may contain <summary> tags.
|
|
61
|
+
For UI previews we want something more informative than the prefix.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
cleaned = summary.replace("<summary>", "\n").replace("</summary>", "\n")
|
|
65
|
+
lines = [line.strip() for line in cleaned.splitlines()]
|
|
66
|
+
prefix = "the conversation history before this point was compacted"
|
|
67
|
+
|
|
68
|
+
def _is_noise(line: str) -> bool:
|
|
69
|
+
if not line:
|
|
70
|
+
return True
|
|
71
|
+
if line.casefold().startswith(prefix):
|
|
72
|
+
return True
|
|
73
|
+
return line in {"---", "----", "-----"}
|
|
74
|
+
|
|
75
|
+
# Prefer the first non-empty line under the "## Goal" section.
|
|
76
|
+
for i, line in enumerate(lines):
|
|
77
|
+
if line == "## Goal":
|
|
78
|
+
for j in range(i + 1, len(lines)):
|
|
79
|
+
candidate = lines[j]
|
|
80
|
+
if _is_noise(candidate):
|
|
81
|
+
continue
|
|
82
|
+
if candidate.startswith("## "):
|
|
83
|
+
break
|
|
84
|
+
return candidate
|
|
85
|
+
|
|
86
|
+
# Otherwise, pick the first non-heading meaningful line.
|
|
87
|
+
for line in lines:
|
|
88
|
+
if _is_noise(line):
|
|
89
|
+
continue
|
|
90
|
+
if line.startswith("## "):
|
|
91
|
+
continue
|
|
92
|
+
return line
|
|
93
|
+
|
|
94
|
+
# Fallback: first non-empty line.
|
|
95
|
+
return _first_non_empty_line(cleaned)
|
|
96
|
+
|
|
97
|
+
|
|
45
98
|
def _build_fork_points(conversation_history: list[message.HistoryEvent]) -> list[ForkPoint]:
|
|
46
99
|
"""Build list of fork points from conversation history.
|
|
47
100
|
|
|
48
101
|
Fork points are:
|
|
49
102
|
- Each UserMessage position (for UI display, including first which would be empty session)
|
|
103
|
+
- The latest CompactionEntry boundary (just after it)
|
|
50
104
|
- The end of the conversation (fork entire conversation)
|
|
51
105
|
"""
|
|
52
106
|
fork_points: list[ForkPoint] = []
|
|
@@ -80,24 +134,44 @@ def _build_fork_points(conversation_history: list[message.HistoryEvent]) -> list
|
|
|
80
134
|
user_text = message.join_text_parts(user_item.parts)
|
|
81
135
|
fork_points.append(
|
|
82
136
|
ForkPoint(
|
|
137
|
+
kind="user",
|
|
83
138
|
history_index=user_idx,
|
|
84
|
-
user_message=user_text or "(empty)",
|
|
85
139
|
tool_call_stats=tool_stats,
|
|
140
|
+
user_message=user_text or "(empty)",
|
|
86
141
|
last_assistant_summary=_truncate(last_assistant_content) if last_assistant_content else "",
|
|
87
142
|
)
|
|
88
143
|
)
|
|
89
144
|
|
|
90
|
-
# Add
|
|
91
|
-
|
|
145
|
+
# Add a fork point just after the latest compaction entry (if any).
|
|
146
|
+
last_compaction_idx = -1
|
|
147
|
+
last_compaction: message.CompactionEntry | None = None
|
|
148
|
+
for idx in range(len(conversation_history) - 1, -1, -1):
|
|
149
|
+
item = conversation_history[idx]
|
|
150
|
+
if isinstance(item, message.CompactionEntry):
|
|
151
|
+
last_compaction_idx = idx
|
|
152
|
+
last_compaction = item
|
|
153
|
+
break
|
|
154
|
+
if last_compaction is not None:
|
|
155
|
+
# `until_index` is exclusive; `idx + 1` means include the CompactionEntry itself.
|
|
156
|
+
boundary_index = min(len(conversation_history), last_compaction_idx + 1)
|
|
157
|
+
preview = _truncate(_preview_compaction_summary(last_compaction.summary), 70)
|
|
92
158
|
fork_points.append(
|
|
93
159
|
ForkPoint(
|
|
94
|
-
|
|
95
|
-
|
|
160
|
+
kind="compaction",
|
|
161
|
+
history_index=boundary_index,
|
|
96
162
|
tool_call_stats={},
|
|
97
|
-
|
|
163
|
+
compaction_summary_preview=preview,
|
|
164
|
+
compaction_first_kept_index=last_compaction.first_kept_index,
|
|
165
|
+
compaction_tokens_before=last_compaction.tokens_before,
|
|
98
166
|
)
|
|
99
167
|
)
|
|
100
168
|
|
|
169
|
+
fork_points.sort(key=lambda fp: fp.history_index)
|
|
170
|
+
|
|
171
|
+
# Add the "fork entire conversation" option at the end
|
|
172
|
+
if fork_points:
|
|
173
|
+
fork_points.append(ForkPoint(kind="end", history_index=-1, tool_call_stats={}))
|
|
174
|
+
|
|
101
175
|
return fork_points
|
|
102
176
|
|
|
103
177
|
|
|
@@ -107,7 +181,6 @@ def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int]]:
|
|
|
107
181
|
|
|
108
182
|
for i, fp in enumerate(fork_points):
|
|
109
183
|
is_first = i == 0
|
|
110
|
-
is_last = i == len(fork_points) - 1
|
|
111
184
|
|
|
112
185
|
# Build the title
|
|
113
186
|
title_parts: list[tuple[str, str]] = []
|
|
@@ -115,12 +188,14 @@ def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int]]:
|
|
|
115
188
|
# First line: separator (with special markers for first/last fork points)
|
|
116
189
|
if is_first:
|
|
117
190
|
pass
|
|
118
|
-
elif
|
|
191
|
+
elif fp.kind == "end":
|
|
119
192
|
title_parts.append(("class:separator", "----- fork from here (entire session) -----\n\n"))
|
|
193
|
+
elif fp.kind == "compaction":
|
|
194
|
+
title_parts.append(("class:separator", "----- fork after compaction -----\n\n"))
|
|
120
195
|
else:
|
|
121
196
|
title_parts.append(("class:separator", "----- fork from here -----\n\n"))
|
|
122
197
|
|
|
123
|
-
if
|
|
198
|
+
if fp.kind == "user":
|
|
124
199
|
# Second line: user message
|
|
125
200
|
title_parts.append(("class:msg", f"user: {_truncate(fp.user_message, 70)}\n"))
|
|
126
201
|
|
|
@@ -133,6 +208,15 @@ def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int]]:
|
|
|
133
208
|
if fp.last_assistant_summary:
|
|
134
209
|
title_parts.append(("class:assistant", f"ai: {fp.last_assistant_summary}\n"))
|
|
135
210
|
|
|
211
|
+
elif fp.kind == "compaction":
|
|
212
|
+
kept_from = fp.compaction_first_kept_index
|
|
213
|
+
if kept_from is not None:
|
|
214
|
+
title_parts.append(("class:meta", f"kept: from history index {kept_from}\n"))
|
|
215
|
+
if fp.compaction_tokens_before is not None:
|
|
216
|
+
title_parts.append(("class:meta", f"tokens: {fp.compaction_tokens_before}\n"))
|
|
217
|
+
if fp.compaction_summary_preview:
|
|
218
|
+
title_parts.append(("class:assistant", f"sum: {fp.compaction_summary_preview}\n"))
|
|
219
|
+
|
|
136
220
|
# Empty line at the end
|
|
137
221
|
title_parts.append(("class:text", "\n"))
|
|
138
222
|
|
|
@@ -140,8 +224,16 @@ def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int]]:
|
|
|
140
224
|
SelectItem(
|
|
141
225
|
title=title_parts,
|
|
142
226
|
value=fp.history_index,
|
|
143
|
-
search_text=
|
|
144
|
-
|
|
227
|
+
search_text=(
|
|
228
|
+
fp.user_message
|
|
229
|
+
if fp.kind == "user"
|
|
230
|
+
else (
|
|
231
|
+
f"compaction {fp.compaction_summary_preview}"
|
|
232
|
+
if fp.kind == "compaction"
|
|
233
|
+
else "fork entire conversation"
|
|
234
|
+
)
|
|
235
|
+
),
|
|
236
|
+
selectable=not (fp.kind == "user" and is_first),
|
|
145
237
|
)
|
|
146
238
|
)
|
|
147
239
|
|
|
@@ -247,7 +339,11 @@ class ForkSessionCommand(CommandABC):
|
|
|
247
339
|
await new_session.wait_for_flush()
|
|
248
340
|
|
|
249
341
|
# Build result message
|
|
250
|
-
|
|
342
|
+
selected_point = next((fp for fp in fork_points if fp.history_index == selected), None)
|
|
343
|
+
if selected_point is not None and selected_point.kind == "compaction":
|
|
344
|
+
fork_description = "after compaction"
|
|
345
|
+
else:
|
|
346
|
+
fork_description = "entire conversation" if selected == -1 else f"up to message index {selected}"
|
|
251
347
|
|
|
252
348
|
resume_cmd = f"klaude --resume {new_session.id}"
|
|
253
349
|
copy_to_clipboard(resume_cmd)
|
|
@@ -79,7 +79,11 @@ def select_model_interactive(
|
|
|
79
79
|
try:
|
|
80
80
|
items = build_model_select_items(result.filtered_models)
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
total_count = len(result.filtered_models)
|
|
83
|
+
if result.filter_hint:
|
|
84
|
+
message = f"Select a model ({total_count}, filtered by '{result.filter_hint}'):"
|
|
85
|
+
else:
|
|
86
|
+
message = f"Select a model ({total_count}):"
|
|
83
87
|
|
|
84
88
|
initial_value = config.main_model
|
|
85
89
|
if isinstance(initial_value, str) and initial_value and "@" not in initial_value:
|
|
@@ -14,7 +14,7 @@ def _select_thinking_sync(config: llm_param.LLMConfigParameter) -> llm_param.Thi
|
|
|
14
14
|
return None
|
|
15
15
|
|
|
16
16
|
items: list[SelectItem[str]] = [
|
|
17
|
-
SelectItem(title=[("class:
|
|
17
|
+
SelectItem(title=[("class:msg", opt.label + "\n")], value=opt.value, search_text=opt.label)
|
|
18
18
|
for opt in data.options
|
|
19
19
|
]
|
|
20
20
|
|
klaude_code/tui/commands.py
CHANGED
|
@@ -162,3 +162,9 @@ class TaskClockStart(RenderCommand):
|
|
|
162
162
|
@dataclass(frozen=True, slots=True)
|
|
163
163
|
class TaskClockClear(RenderCommand):
|
|
164
164
|
pass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True, slots=True)
|
|
168
|
+
class RenderCompactionSummary(RenderCommand):
|
|
169
|
+
summary: str
|
|
170
|
+
kept_items_brief: tuple[tuple[str, int, str], ...] = () # (item_type, count, preview)
|
|
@@ -10,9 +10,11 @@ from typing import Any, ClassVar
|
|
|
10
10
|
from markdown_it import MarkdownIt
|
|
11
11
|
from markdown_it.token import Token
|
|
12
12
|
from rich import box
|
|
13
|
+
from rich._loop import loop_first
|
|
13
14
|
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
|
14
|
-
from rich.markdown import CodeBlock, Heading, Markdown, MarkdownElement, TableElement
|
|
15
|
+
from rich.markdown import CodeBlock, Heading, ListItem, Markdown, MarkdownElement, TableElement
|
|
15
16
|
from rich.rule import Rule
|
|
17
|
+
from rich.segment import Segment
|
|
16
18
|
from rich.style import Style, StyleType
|
|
17
19
|
from rich.syntax import Syntax
|
|
18
20
|
from rich.table import Table
|
|
@@ -34,6 +36,9 @@ _THINKING_HTML_BLOCK_RE = re.compile(
|
|
|
34
36
|
|
|
35
37
|
_HTML_COMMENT_BLOCK_RE = re.compile(r"\A\s*<!--.*?-->\s*\Z", flags=re.DOTALL)
|
|
36
38
|
|
|
39
|
+
_CHECKBOX_UNCHECKED_RE = re.compile(r"^\[ \]\s*")
|
|
40
|
+
_CHECKBOX_CHECKED_RE = re.compile(r"^\[x\]\s*", re.IGNORECASE)
|
|
41
|
+
|
|
37
42
|
|
|
38
43
|
class ThinkingHTMLBlock(MarkdownElement):
|
|
39
44
|
"""Render `<thinking>...</thinking>` HTML blocks as Rich Markdown.
|
|
@@ -153,6 +158,55 @@ class LeftHeading(Heading):
|
|
|
153
158
|
yield text
|
|
154
159
|
|
|
155
160
|
|
|
161
|
+
class CheckboxListItem(ListItem):
|
|
162
|
+
"""A list item that renders checkbox syntax as Unicode symbols."""
|
|
163
|
+
|
|
164
|
+
def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
165
|
+
render_options = options.update(width=options.max_width - 3)
|
|
166
|
+
lines = console.render_lines(self.elements, render_options, style=self.style)
|
|
167
|
+
bullet_style = console.get_style("markdown.item.bullet", default="none")
|
|
168
|
+
|
|
169
|
+
first_line_text = ""
|
|
170
|
+
if lines:
|
|
171
|
+
first_line_text = "".join(seg.text for seg in lines[0] if seg.text)
|
|
172
|
+
|
|
173
|
+
unchecked_match = _CHECKBOX_UNCHECKED_RE.match(first_line_text)
|
|
174
|
+
checked_match = _CHECKBOX_CHECKED_RE.match(first_line_text)
|
|
175
|
+
|
|
176
|
+
if unchecked_match:
|
|
177
|
+
bullet = Segment(" \u2610 ", bullet_style)
|
|
178
|
+
skip_chars = len(unchecked_match.group(0))
|
|
179
|
+
elif checked_match:
|
|
180
|
+
checked_style = console.get_style("markdown.checkbox.checked", default="none")
|
|
181
|
+
bullet = Segment(" \u2713 ", checked_style)
|
|
182
|
+
skip_chars = len(checked_match.group(0))
|
|
183
|
+
else:
|
|
184
|
+
bullet = Segment(" \u2022 ", bullet_style)
|
|
185
|
+
skip_chars = 0
|
|
186
|
+
|
|
187
|
+
padding = Segment(" " * 3, bullet_style)
|
|
188
|
+
new_line = Segment("\n")
|
|
189
|
+
|
|
190
|
+
for first, line in loop_first(lines):
|
|
191
|
+
yield bullet if first else padding
|
|
192
|
+
if first and skip_chars > 0:
|
|
193
|
+
chars_skipped = 0
|
|
194
|
+
for seg in line:
|
|
195
|
+
if seg.text and chars_skipped < skip_chars:
|
|
196
|
+
remaining = skip_chars - chars_skipped
|
|
197
|
+
if len(seg.text) <= remaining:
|
|
198
|
+
chars_skipped += len(seg.text)
|
|
199
|
+
continue
|
|
200
|
+
else:
|
|
201
|
+
yield Segment(seg.text[remaining:], seg.style)
|
|
202
|
+
chars_skipped = skip_chars
|
|
203
|
+
else:
|
|
204
|
+
yield seg
|
|
205
|
+
else:
|
|
206
|
+
yield from line
|
|
207
|
+
yield new_line
|
|
208
|
+
|
|
209
|
+
|
|
156
210
|
class NoInsetMarkdown(Markdown):
|
|
157
211
|
"""Markdown with code blocks that have no padding and left-justified headings."""
|
|
158
212
|
|
|
@@ -164,6 +218,7 @@ class NoInsetMarkdown(Markdown):
|
|
|
164
218
|
"hr": Divider,
|
|
165
219
|
"table_open": MarkdownTable,
|
|
166
220
|
"html_block": ThinkingHTMLBlock,
|
|
221
|
+
"list_item_open": CheckboxListItem,
|
|
167
222
|
}
|
|
168
223
|
|
|
169
224
|
|
|
@@ -178,6 +233,7 @@ class ThinkingMarkdown(Markdown):
|
|
|
178
233
|
"hr": Divider,
|
|
179
234
|
"table_open": MarkdownTable,
|
|
180
235
|
"html_block": ThinkingHTMLBlock,
|
|
236
|
+
"list_item_open": CheckboxListItem,
|
|
181
237
|
}
|
|
182
238
|
|
|
183
239
|
|
|
@@ -95,7 +95,7 @@ DARK_PALETTE = Palette(
|
|
|
95
95
|
code_theme="ansi_dark",
|
|
96
96
|
code_background="#1a1f2a",
|
|
97
97
|
green_background="#23342c",
|
|
98
|
-
blue_grey_background="#
|
|
98
|
+
blue_grey_background="#262d3a",
|
|
99
99
|
cyan_background="#1a3333",
|
|
100
100
|
green_sub_background="#1b3928",
|
|
101
101
|
blue_sub_background="#1a2a3d",
|
|
@@ -116,6 +116,7 @@ class ThemeKey(str, Enum):
|
|
|
116
116
|
# PANEL
|
|
117
117
|
SUB_AGENT_RESULT_PANEL = "panel.sub_agent_result"
|
|
118
118
|
WRITE_MARKDOWN_PANEL = "panel.write_markdown"
|
|
119
|
+
COMPACTION_SUMMARY_PANEL = "panel.compaction_summary"
|
|
119
120
|
# DIFF
|
|
120
121
|
DIFF_FILE_NAME = "diff.file_name"
|
|
121
122
|
DIFF_REMOVE = "diff.remove"
|
|
@@ -178,6 +179,8 @@ class ThemeKey(str, Enum):
|
|
|
178
179
|
# THINKING
|
|
179
180
|
THINKING = "thinking"
|
|
180
181
|
THINKING_BOLD = "thinking.bold"
|
|
182
|
+
# COMPACTION
|
|
183
|
+
COMPACTION_SUMMARY = "compaction.summary"
|
|
181
184
|
# TODO_ITEM
|
|
182
185
|
TODO_EXPLANATION = "todo.explanation"
|
|
183
186
|
TODO_PENDING_MARK = "todo.pending.mark"
|
|
@@ -235,6 +238,7 @@ def get_theme(theme: str | None = None) -> Themes:
|
|
|
235
238
|
# PANEL
|
|
236
239
|
ThemeKey.SUB_AGENT_RESULT_PANEL.value: f"on {palette.blue_grey_background}",
|
|
237
240
|
ThemeKey.WRITE_MARKDOWN_PANEL.value: f"on {palette.green_background}",
|
|
241
|
+
ThemeKey.COMPACTION_SUMMARY_PANEL.value: f"on {palette.blue_grey_background}",
|
|
238
242
|
# DIFF
|
|
239
243
|
ThemeKey.DIFF_FILE_NAME.value: palette.blue,
|
|
240
244
|
ThemeKey.DIFF_REMOVE.value: palette.diff_remove,
|
|
@@ -247,7 +251,7 @@ def get_theme(theme: str | None = None) -> Themes:
|
|
|
247
251
|
ThemeKey.ERROR.value: palette.red,
|
|
248
252
|
ThemeKey.ERROR_BOLD.value: "bold " + palette.red,
|
|
249
253
|
ThemeKey.ERROR_DIM.value: "dim " + palette.red,
|
|
250
|
-
ThemeKey.INTERRUPT.value:
|
|
254
|
+
ThemeKey.INTERRUPT.value: palette.red,
|
|
251
255
|
# USER_INPUT
|
|
252
256
|
ThemeKey.USER_INPUT.value: palette.magenta,
|
|
253
257
|
ThemeKey.USER_INPUT_PROMPT.value: "bold " + palette.magenta,
|
|
@@ -296,6 +300,8 @@ def get_theme(theme: str | None = None) -> Themes:
|
|
|
296
300
|
# THINKING
|
|
297
301
|
ThemeKey.THINKING.value: "italic " + palette.grey2,
|
|
298
302
|
ThemeKey.THINKING_BOLD.value: "bold italic " + palette.grey1,
|
|
303
|
+
# COMPACTION
|
|
304
|
+
ThemeKey.COMPACTION_SUMMARY.value: "italic " + palette.grey1,
|
|
299
305
|
# TODO_ITEM
|
|
300
306
|
ThemeKey.TODO_EXPLANATION.value: palette.grey1 + " italic",
|
|
301
307
|
ThemeKey.TODO_PENDING_MARK.value: "bold " + palette.grey1,
|
|
@@ -350,6 +356,7 @@ def get_theme(theme: str | None = None) -> Themes:
|
|
|
350
356
|
"markdown.link": "underline " + palette.blue,
|
|
351
357
|
"markdown.link_url": "underline " + palette.blue,
|
|
352
358
|
"markdown.table.border": palette.grey2,
|
|
359
|
+
"markdown.checkbox.checked": palette.green,
|
|
353
360
|
}
|
|
354
361
|
),
|
|
355
362
|
thinking_markdown_theme=Theme(
|
|
@@ -372,6 +379,7 @@ def get_theme(theme: str | None = None) -> Themes:
|
|
|
372
379
|
"markdown.link_url": "underline " + palette.blue,
|
|
373
380
|
"markdown.strong": "bold italic " + palette.grey1,
|
|
374
381
|
"markdown.table.border": palette.grey2,
|
|
382
|
+
"markdown.checkbox.checked": palette.green,
|
|
375
383
|
}
|
|
376
384
|
),
|
|
377
385
|
code_theme=palette.code_theme,
|
|
@@ -263,11 +263,7 @@ def render_write_tool_call(arguments: str) -> RenderableType:
|
|
|
263
263
|
try:
|
|
264
264
|
json_dict = json.loads(arguments)
|
|
265
265
|
file_path = json_dict.get("file_path", "")
|
|
266
|
-
|
|
267
|
-
if file_path.endswith(".md"):
|
|
268
|
-
details: RenderableType | None = None
|
|
269
|
-
else:
|
|
270
|
-
details = render_path(file_path, ThemeKey.TOOL_PARAM_FILE_PATH)
|
|
266
|
+
details: RenderableType | None = render_path(file_path, ThemeKey.TOOL_PARAM_FILE_PATH)
|
|
271
267
|
except json.JSONDecodeError:
|
|
272
268
|
details = Text(
|
|
273
269
|
arguments.strip()[:INVALID_TOOL_CALL_MAX_LENGTH],
|
|
@@ -292,24 +288,29 @@ def render_apply_patch_tool_call(arguments: str) -> RenderableType:
|
|
|
292
288
|
details = Text("", ThemeKey.TOOL_PARAM)
|
|
293
289
|
|
|
294
290
|
if isinstance(patch_content, str):
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
291
|
+
update_files: list[str] = []
|
|
292
|
+
add_files: list[str] = []
|
|
293
|
+
delete_files: list[str] = []
|
|
298
294
|
for line in patch_content.splitlines():
|
|
299
295
|
if line.startswith("*** Update File:"):
|
|
300
|
-
|
|
296
|
+
update_files.append(line[len("*** Update File:") :].strip())
|
|
301
297
|
elif line.startswith("*** Add File:"):
|
|
302
|
-
|
|
298
|
+
add_files.append(line[len("*** Add File:") :].strip())
|
|
303
299
|
elif line.startswith("*** Delete File:"):
|
|
304
|
-
|
|
300
|
+
delete_files.append(line[len("*** Delete File:") :].strip())
|
|
305
301
|
|
|
306
302
|
parts: list[str] = []
|
|
307
|
-
if
|
|
308
|
-
parts.append(f"Update File × {
|
|
309
|
-
if
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
303
|
+
if update_files:
|
|
304
|
+
parts.append(f"Update File × {len(update_files)}" if len(update_files) > 1 else "Update File")
|
|
305
|
+
if add_files:
|
|
306
|
+
# For single .md file addition, show filename in parentheses
|
|
307
|
+
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})")
|
|
310
|
+
else:
|
|
311
|
+
parts.append(f"Add File × {len(add_files)}" if len(add_files) > 1 else "Add File")
|
|
312
|
+
if delete_files:
|
|
313
|
+
parts.append(f"Delete File × {len(delete_files)}" if len(delete_files) > 1 else "Delete File")
|
|
313
314
|
|
|
314
315
|
if parts:
|
|
315
316
|
details = Text(", ".join(parts), ThemeKey.TOOL_PARAM)
|
|
@@ -593,14 +594,24 @@ def _extract_markdown_doc(ui_extra: model.ToolResultUIExtra | None) -> model.Mar
|
|
|
593
594
|
|
|
594
595
|
|
|
595
596
|
def render_markdown_doc(md_ui: model.MarkdownDocUIExtra, *, code_theme: str) -> RenderableType:
|
|
596
|
-
"""Render markdown document content in a panel."""
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
597
|
+
"""Render markdown document content in a panel with 2-char left indent and top margin."""
|
|
598
|
+
import shutil
|
|
599
|
+
|
|
600
|
+
from rich.padding import Padding
|
|
601
|
+
|
|
602
|
+
# Limit panel width to min(100, terminal_width) minus left indent (2)
|
|
603
|
+
terminal_width = shutil.get_terminal_size().columns
|
|
604
|
+
panel_width = min(100, terminal_width) - 2
|
|
605
|
+
|
|
606
|
+
panel = Panel(
|
|
607
|
+
NoInsetMarkdown(md_ui.content, code_theme=code_theme),
|
|
600
608
|
box=box.SIMPLE,
|
|
601
609
|
border_style=ThemeKey.LINES,
|
|
602
610
|
style=ThemeKey.WRITE_MARKDOWN_PANEL,
|
|
611
|
+
width=panel_width,
|
|
603
612
|
)
|
|
613
|
+
# (top, right, bottom, left) - 1 line top margin, 2-char left indent
|
|
614
|
+
return Padding(panel, (1, 0, 0, 2))
|
|
604
615
|
|
|
605
616
|
|
|
606
617
|
def render_tool_result(
|
|
@@ -628,11 +639,12 @@ def render_tool_result(
|
|
|
628
639
|
rendered: list[RenderableType] = []
|
|
629
640
|
for item in e.ui_extra.items:
|
|
630
641
|
if isinstance(item, model.MarkdownDocUIExtra):
|
|
642
|
+
# Markdown docs render without TreeQuote wrap (already has 2-char indent)
|
|
631
643
|
rendered.append(render_markdown_doc(item, code_theme=code_theme))
|
|
632
644
|
elif isinstance(item, model.DiffUIExtra):
|
|
633
645
|
show_file_name = e.tool_name == tools.APPLY_PATCH
|
|
634
|
-
rendered.append(r_diffs.render_structured_diff(item, show_file_name=show_file_name))
|
|
635
|
-
return
|
|
646
|
+
rendered.append(wrap(r_diffs.render_structured_diff(item, show_file_name=show_file_name)))
|
|
647
|
+
return Group(*rendered) if rendered else None
|
|
636
648
|
|
|
637
649
|
diff_ui = _extract_diff(e.ui_extra)
|
|
638
650
|
md_ui = _extract_markdown_doc(e.ui_extra)
|
|
@@ -649,11 +661,13 @@ def render_tool_result(
|
|
|
649
661
|
return wrap(r_diffs.render_structured_diff(diff_ui) if diff_ui else Text(""))
|
|
650
662
|
case tools.WRITE:
|
|
651
663
|
if md_ui:
|
|
652
|
-
|
|
664
|
+
# Markdown docs render without TreeQuote wrap (already has 2-char indent)
|
|
665
|
+
return render_markdown_doc(md_ui, code_theme=code_theme)
|
|
653
666
|
return wrap(r_diffs.render_structured_diff(diff_ui) if diff_ui else Text(""))
|
|
654
667
|
case tools.APPLY_PATCH:
|
|
655
668
|
if md_ui:
|
|
656
|
-
|
|
669
|
+
# Markdown docs render without TreeQuote wrap (already has 2-char indent)
|
|
670
|
+
return render_markdown_doc(md_ui, code_theme=code_theme)
|
|
657
671
|
if diff_ui:
|
|
658
672
|
return wrap(r_diffs.render_structured_diff(diff_ui, show_file_name=True))
|
|
659
673
|
return _render_fallback()
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
from klaude_code.tui.input.prompt_toolkit import REPLStatusSnapshot
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
def build_repl_status_snapshot(
|
|
4
|
+
def build_repl_status_snapshot(
|
|
5
|
+
update_message: str | None,
|
|
6
|
+
debug_log_path: str | None = None,
|
|
7
|
+
) -> REPLStatusSnapshot:
|
|
5
8
|
"""Build a status snapshot for the REPL bottom toolbar."""
|
|
6
|
-
return REPLStatusSnapshot(update_message=update_message)
|
|
9
|
+
return REPLStatusSnapshot(update_message=update_message, debug_log_path=debug_log_path)
|
|
@@ -13,7 +13,6 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import contextlib
|
|
15
15
|
import re
|
|
16
|
-
import shlex
|
|
17
16
|
from pathlib import Path
|
|
18
17
|
from urllib.parse import unquote, urlparse
|
|
19
18
|
|
|
@@ -132,66 +131,16 @@ def _replace_file_uris(
|
|
|
132
131
|
return out, changed
|
|
133
132
|
|
|
134
133
|
|
|
135
|
-
def _looks_like_path_list(text: str) -> list[str] | None:
|
|
136
|
-
"""Return tokens if text looks like a pure path list, else None."""
|
|
137
|
-
|
|
138
|
-
stripped = text.strip()
|
|
139
|
-
if not stripped:
|
|
140
|
-
return None
|
|
141
|
-
|
|
142
|
-
# Avoid converting when the paste already contains our input syntax.
|
|
143
|
-
if "@" in stripped or "[image " in stripped:
|
|
144
|
-
return None
|
|
145
|
-
|
|
146
|
-
try:
|
|
147
|
-
tokens = shlex.split(stripped, posix=True)
|
|
148
|
-
except ValueError:
|
|
149
|
-
return None
|
|
150
|
-
|
|
151
|
-
if not tokens:
|
|
152
|
-
return None
|
|
153
|
-
|
|
154
|
-
# Heuristic: all tokens must exist on disk.
|
|
155
|
-
for tok in tokens:
|
|
156
|
-
p = Path(tok).expanduser()
|
|
157
|
-
try:
|
|
158
|
-
if not p.exists():
|
|
159
|
-
return None
|
|
160
|
-
except OSError:
|
|
161
|
-
return None
|
|
162
|
-
|
|
163
|
-
return tokens
|
|
164
|
-
|
|
165
|
-
|
|
166
134
|
def convert_dropped_text(
|
|
167
135
|
text: str,
|
|
168
136
|
*,
|
|
169
137
|
cwd: Path,
|
|
170
138
|
) -> str:
|
|
171
|
-
"""Convert drag-and-drop
|
|
172
|
-
|
|
173
|
-
out, changed = _replace_file_uris(text, cwd=cwd)
|
|
174
|
-
if changed:
|
|
175
|
-
return out
|
|
176
|
-
|
|
177
|
-
tokens = _looks_like_path_list(text)
|
|
178
|
-
if not tokens:
|
|
179
|
-
return text
|
|
139
|
+
"""Convert drag-and-drop file:// URIs into @ tokens and/or image markers.
|
|
180
140
|
|
|
181
|
-
converted
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
try:
|
|
185
|
-
is_img = p.exists() and p.is_file() and is_image_file(p)
|
|
186
|
-
except OSError:
|
|
187
|
-
is_img = False
|
|
188
|
-
|
|
189
|
-
if is_img:
|
|
190
|
-
converted.append(format_image_marker(_normalize_path_for_at(p, cwd=cwd)))
|
|
191
|
-
continue
|
|
192
|
-
|
|
193
|
-
converted.append(_format_at_token(_normalize_path_for_at(p, cwd=cwd)))
|
|
141
|
+
Only file:// URIs are converted. Plain paths are not auto-converted to avoid
|
|
142
|
+
unintended transformations when users paste regular path strings.
|
|
143
|
+
"""
|
|
194
144
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
return " ".join(converted) + suffix
|
|
145
|
+
out, _ = _replace_file_uris(text, cwd=cwd)
|
|
146
|
+
return out
|
|
@@ -578,4 +578,14 @@ def create_key_bindings(
|
|
|
578
578
|
with contextlib.suppress(Exception):
|
|
579
579
|
open_thinking_picker()
|
|
580
580
|
|
|
581
|
+
@kb.add("escape", "up", filter=enabled & ~has_completions)
|
|
582
|
+
def _(event: KeyPressEvent) -> None:
|
|
583
|
+
"""Option+Up switches to previous history entry."""
|
|
584
|
+
event.current_buffer.history_backward()
|
|
585
|
+
|
|
586
|
+
@kb.add("escape", "down", filter=enabled & ~has_completions)
|
|
587
|
+
def _(event: KeyPressEvent) -> None:
|
|
588
|
+
"""Option+Down switches to next history entry."""
|
|
589
|
+
event.current_buffer.history_forward()
|
|
590
|
+
|
|
581
591
|
return kb
|