klaude-code 2.7.0__py3-none-any.whl → 2.8.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.
- 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 +2 -2
- 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 +2 -1
- klaude_code/core/agent.py +5 -1
- klaude_code/core/agent_profile.py +29 -33
- klaude_code/core/compaction/AGENTS.md +112 -0
- klaude_code/core/compaction/__init__.py +11 -0
- klaude_code/core/compaction/compaction.py +705 -0
- klaude_code/core/compaction/overflow.py +30 -0
- klaude_code/core/compaction/prompts.py +97 -0
- klaude_code/core/executor.py +121 -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/file/edit_tool.py +1 -2
- 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/commands.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 +29 -1
- klaude_code/protocol/op_handler.py +10 -0
- klaude_code/session/export.py +308 -299
- klaude_code/session/session.py +36 -0
- klaude_code/session/templates/export_session.html +430 -134
- klaude_code/skill/assets/create-plan/SKILL.md +6 -6
- klaude_code/tui/command/__init__.py +6 -0
- klaude_code/tui/command/compact_cmd.py +32 -0
- klaude_code/tui/command/continue_cmd.py +34 -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 +119 -12
- 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 +68 -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.1.dist-info}/METADATA +1 -1
- {klaude_code-2.7.0.dist-info → klaude_code-2.8.1.dist-info}/RECORD +73 -56
- 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.1.dist-info}/WHEEL +0 -0
- {klaude_code-2.7.0.dist-info → klaude_code-2.8.1.dist-info}/entry_points.txt +0 -0
|
@@ -30,6 +30,8 @@ def ensure_commands_loaded() -> None:
|
|
|
30
30
|
|
|
31
31
|
# Import and register commands in display order
|
|
32
32
|
from .clear_cmd import ClearCommand
|
|
33
|
+
from .compact_cmd import CompactCommand
|
|
34
|
+
from .continue_cmd import ContinueCommand
|
|
33
35
|
from .copy_cmd import CopyCommand
|
|
34
36
|
from .debug_cmd import DebugCommand
|
|
35
37
|
from .export_cmd import ExportCommand
|
|
@@ -45,6 +47,8 @@ def ensure_commands_loaded() -> None:
|
|
|
45
47
|
# Register in desired display order
|
|
46
48
|
register(CopyCommand())
|
|
47
49
|
register(ExportCommand())
|
|
50
|
+
register(CompactCommand())
|
|
51
|
+
register(ContinueCommand())
|
|
48
52
|
register(RefreshTerminalCommand())
|
|
49
53
|
register(ModelCommand())
|
|
50
54
|
register(SubAgentModelCommand())
|
|
@@ -64,6 +68,8 @@ def ensure_commands_loaded() -> None:
|
|
|
64
68
|
def __getattr__(name: str) -> object:
|
|
65
69
|
_commands_map = {
|
|
66
70
|
"ClearCommand": "clear_cmd",
|
|
71
|
+
"CompactCommand": "compact_cmd",
|
|
72
|
+
"ContinueCommand": "continue_cmd",
|
|
67
73
|
"CopyCommand": "copy_cmd",
|
|
68
74
|
"DebugCommand": "debug_cmd",
|
|
69
75
|
"ExportCommand": "export_cmd",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from klaude_code.protocol import commands, message, op
|
|
2
|
+
from klaude_code.tui.command.command_abc import Agent, CommandABC, CommandResult
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CompactCommand(CommandABC):
|
|
6
|
+
@property
|
|
7
|
+
def name(self) -> commands.CommandName:
|
|
8
|
+
return commands.CommandName.COMPACT
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
def summary(self) -> str:
|
|
12
|
+
return "summarize older context to free up the model window"
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def support_addition_params(self) -> bool:
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def placeholder(self) -> str:
|
|
20
|
+
return "optional focus for the summary"
|
|
21
|
+
|
|
22
|
+
async def run(self, agent: Agent, user_input: message.UserInputPayload) -> CommandResult:
|
|
23
|
+
focus = user_input.text.strip() if user_input.text else None
|
|
24
|
+
return CommandResult(
|
|
25
|
+
operations=[
|
|
26
|
+
op.CompactSessionOperation(
|
|
27
|
+
session_id=agent.session.id,
|
|
28
|
+
reason="manual",
|
|
29
|
+
focus=focus or None,
|
|
30
|
+
)
|
|
31
|
+
]
|
|
32
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from klaude_code.protocol import commands, events, message, op
|
|
2
|
+
|
|
3
|
+
from .command_abc import Agent, CommandABC, CommandResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ContinueCommand(CommandABC):
|
|
7
|
+
"""Continue agent execution without adding a new user message."""
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def name(self) -> commands.CommandName:
|
|
11
|
+
return commands.CommandName.CONTINUE
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def summary(self) -> str:
|
|
15
|
+
return "Continue agent execution (for recovery after interruptions)"
|
|
16
|
+
|
|
17
|
+
async def run(self, agent: Agent, user_input: message.UserInputPayload) -> CommandResult:
|
|
18
|
+
del user_input # unused
|
|
19
|
+
|
|
20
|
+
if agent.session.messages_count == 0:
|
|
21
|
+
return CommandResult(
|
|
22
|
+
events=[
|
|
23
|
+
events.CommandOutputEvent(
|
|
24
|
+
session_id=agent.session.id,
|
|
25
|
+
command_name=self.name,
|
|
26
|
+
content="Cannot continue: no conversation history. Start a conversation first.",
|
|
27
|
+
is_error=True,
|
|
28
|
+
)
|
|
29
|
+
]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return CommandResult(
|
|
33
|
+
operations=[op.ContinueAgentOperation(session_id=agent.session.id)],
|
|
34
|
+
)
|
|
@@ -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)
|
|
@@ -5,14 +5,17 @@ import io
|
|
|
5
5
|
import re
|
|
6
6
|
import time
|
|
7
7
|
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
8
9
|
from typing import Any, ClassVar
|
|
9
10
|
|
|
10
11
|
from markdown_it import MarkdownIt
|
|
11
12
|
from markdown_it.token import Token
|
|
12
13
|
from rich import box
|
|
14
|
+
from rich._loop import loop_first
|
|
13
15
|
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
|
14
|
-
from rich.markdown import CodeBlock, Heading, Markdown, MarkdownElement, TableElement
|
|
16
|
+
from rich.markdown import CodeBlock, Heading, ImageItem, ListItem, Markdown, MarkdownElement, TableElement
|
|
15
17
|
from rich.rule import Rule
|
|
18
|
+
from rich.segment import Segment
|
|
16
19
|
from rich.style import Style, StyleType
|
|
17
20
|
from rich.syntax import Syntax
|
|
18
21
|
from rich.table import Table
|
|
@@ -34,6 +37,9 @@ _THINKING_HTML_BLOCK_RE = re.compile(
|
|
|
34
37
|
|
|
35
38
|
_HTML_COMMENT_BLOCK_RE = re.compile(r"\A\s*<!--.*?-->\s*\Z", flags=re.DOTALL)
|
|
36
39
|
|
|
40
|
+
_CHECKBOX_UNCHECKED_RE = re.compile(r"^\[ \]\s*")
|
|
41
|
+
_CHECKBOX_CHECKED_RE = re.compile(r"^\[x\]\s*", re.IGNORECASE)
|
|
42
|
+
|
|
37
43
|
|
|
38
44
|
class ThinkingHTMLBlock(MarkdownElement):
|
|
39
45
|
"""Render `<thinking>...</thinking>` HTML blocks as Rich Markdown.
|
|
@@ -153,6 +159,74 @@ class LeftHeading(Heading):
|
|
|
153
159
|
yield text
|
|
154
160
|
|
|
155
161
|
|
|
162
|
+
class CheckboxListItem(ListItem):
|
|
163
|
+
"""A list item that renders checkbox syntax as Unicode symbols."""
|
|
164
|
+
|
|
165
|
+
def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
166
|
+
render_options = options.update(width=options.max_width - 3)
|
|
167
|
+
lines = console.render_lines(self.elements, render_options, style=self.style)
|
|
168
|
+
bullet_style = console.get_style("markdown.item.bullet", default="none")
|
|
169
|
+
|
|
170
|
+
first_line_text = ""
|
|
171
|
+
if lines:
|
|
172
|
+
first_line_text = "".join(seg.text for seg in lines[0] if seg.text)
|
|
173
|
+
|
|
174
|
+
unchecked_match = _CHECKBOX_UNCHECKED_RE.match(first_line_text)
|
|
175
|
+
checked_match = _CHECKBOX_CHECKED_RE.match(first_line_text)
|
|
176
|
+
|
|
177
|
+
if unchecked_match:
|
|
178
|
+
bullet = Segment(" \u2610 ", bullet_style)
|
|
179
|
+
skip_chars = len(unchecked_match.group(0))
|
|
180
|
+
elif checked_match:
|
|
181
|
+
checked_style = console.get_style("markdown.checkbox.checked", default="none")
|
|
182
|
+
bullet = Segment(" \u2713 ", checked_style)
|
|
183
|
+
skip_chars = len(checked_match.group(0))
|
|
184
|
+
else:
|
|
185
|
+
bullet = Segment(" \u2022 ", bullet_style)
|
|
186
|
+
skip_chars = 0
|
|
187
|
+
|
|
188
|
+
padding = Segment(" " * 3, bullet_style)
|
|
189
|
+
new_line = Segment("\n")
|
|
190
|
+
|
|
191
|
+
for first, line in loop_first(lines):
|
|
192
|
+
yield bullet if first else padding
|
|
193
|
+
if first and skip_chars > 0:
|
|
194
|
+
chars_skipped = 0
|
|
195
|
+
for seg in line:
|
|
196
|
+
if seg.text and chars_skipped < skip_chars:
|
|
197
|
+
remaining = skip_chars - chars_skipped
|
|
198
|
+
if len(seg.text) <= remaining:
|
|
199
|
+
chars_skipped += len(seg.text)
|
|
200
|
+
continue
|
|
201
|
+
else:
|
|
202
|
+
yield Segment(seg.text[remaining:], seg.style)
|
|
203
|
+
chars_skipped = skip_chars
|
|
204
|
+
else:
|
|
205
|
+
yield seg
|
|
206
|
+
else:
|
|
207
|
+
yield from line
|
|
208
|
+
yield new_line
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class LocalImageItem(ImageItem):
|
|
212
|
+
"""Image element that collects local file paths for external rendering."""
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
|
|
216
|
+
src = str(token.attrs.get("src", ""))
|
|
217
|
+
instance = cls(src, markdown.hyperlinks)
|
|
218
|
+
if src.startswith("/") and Path(src).exists():
|
|
219
|
+
collected = getattr(markdown, "collected_images", None)
|
|
220
|
+
if collected is not None:
|
|
221
|
+
collected.append(src)
|
|
222
|
+
return instance
|
|
223
|
+
|
|
224
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
225
|
+
if self.destination.startswith("/") and Path(self.destination).exists():
|
|
226
|
+
return
|
|
227
|
+
yield from super().__rich_console__(console, options)
|
|
228
|
+
|
|
229
|
+
|
|
156
230
|
class NoInsetMarkdown(Markdown):
|
|
157
231
|
"""Markdown with code blocks that have no padding and left-justified headings."""
|
|
158
232
|
|
|
@@ -164,8 +238,14 @@ class NoInsetMarkdown(Markdown):
|
|
|
164
238
|
"hr": Divider,
|
|
165
239
|
"table_open": MarkdownTable,
|
|
166
240
|
"html_block": ThinkingHTMLBlock,
|
|
241
|
+
"list_item_open": CheckboxListItem,
|
|
242
|
+
"image": LocalImageItem,
|
|
167
243
|
}
|
|
168
244
|
|
|
245
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
246
|
+
super().__init__(*args, **kwargs)
|
|
247
|
+
self.collected_images: list[str] = []
|
|
248
|
+
|
|
169
249
|
|
|
170
250
|
class ThinkingMarkdown(Markdown):
|
|
171
251
|
"""Markdown for thinking content with grey-styled code blocks and left-justified headings."""
|
|
@@ -178,8 +258,14 @@ class ThinkingMarkdown(Markdown):
|
|
|
178
258
|
"hr": Divider,
|
|
179
259
|
"table_open": MarkdownTable,
|
|
180
260
|
"html_block": ThinkingHTMLBlock,
|
|
261
|
+
"list_item_open": CheckboxListItem,
|
|
262
|
+
"image": LocalImageItem,
|
|
181
263
|
}
|
|
182
264
|
|
|
265
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
266
|
+
super().__init__(*args, **kwargs)
|
|
267
|
+
self.collected_images: list[str] = []
|
|
268
|
+
|
|
183
269
|
|
|
184
270
|
class MarkdownStream:
|
|
185
271
|
"""Block-based streaming Markdown renderer.
|
|
@@ -204,6 +290,7 @@ class MarkdownStream:
|
|
|
204
290
|
left_margin: int = 0,
|
|
205
291
|
right_margin: int = MARKDOWN_RIGHT_MARGIN,
|
|
206
292
|
markdown_class: Callable[..., Markdown] | None = None,
|
|
293
|
+
image_callback: Callable[[str], None] | None = None,
|
|
207
294
|
) -> None:
|
|
208
295
|
"""Initialize the markdown stream.
|
|
209
296
|
|
|
@@ -216,6 +303,7 @@ class MarkdownStream:
|
|
|
216
303
|
left_margin (int, optional): Number of columns to reserve on the left side
|
|
217
304
|
right_margin (int, optional): Number of columns to reserve on the right side
|
|
218
305
|
markdown_class: Markdown class to use for rendering (defaults to NoInsetMarkdown)
|
|
306
|
+
image_callback: Callback to display local images (called with file path)
|
|
219
307
|
"""
|
|
220
308
|
self._stable_rendered_lines: list[str] = []
|
|
221
309
|
self._stable_source_line_count: int = 0
|
|
@@ -226,6 +314,8 @@ class MarkdownStream:
|
|
|
226
314
|
self.mdargs = {}
|
|
227
315
|
|
|
228
316
|
self._live_sink = live_sink
|
|
317
|
+
self._image_callback = image_callback
|
|
318
|
+
self._displayed_images: set[str] = set()
|
|
229
319
|
|
|
230
320
|
# Streaming control
|
|
231
321
|
self.when: float = 0.0 # Timestamp of last update
|
|
@@ -365,20 +455,24 @@ class MarkdownStream:
|
|
|
365
455
|
|
|
366
456
|
This is primarily intended for internal debugging and tests.
|
|
367
457
|
"""
|
|
458
|
+
lines, _ = self._render_markdown_to_lines(text, apply_mark=apply_mark)
|
|
459
|
+
return "".join(lines)
|
|
368
460
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
def render_stable_ansi(self, stable_source: str, *, has_live_suffix: bool, final: bool) -> str:
|
|
372
|
-
"""Render stable prefix to ANSI, preserving inter-block spacing."""
|
|
461
|
+
def render_stable_ansi(self, stable_source: str, *, has_live_suffix: bool, final: bool) -> tuple[str, list[str]]:
|
|
462
|
+
"""Render stable prefix to ANSI, preserving inter-block spacing.
|
|
373
463
|
|
|
464
|
+
Returns:
|
|
465
|
+
tuple: (ANSI string, collected local image paths)
|
|
466
|
+
"""
|
|
374
467
|
if not stable_source:
|
|
375
|
-
return ""
|
|
468
|
+
return "", []
|
|
376
469
|
|
|
377
470
|
render_source = stable_source
|
|
378
471
|
if not final and has_live_suffix:
|
|
379
472
|
render_source = self._append_nonfinal_sentinel(stable_source)
|
|
380
473
|
|
|
381
|
-
|
|
474
|
+
lines, images = self._render_markdown_to_lines(render_source, apply_mark=True)
|
|
475
|
+
return "".join(lines), images
|
|
382
476
|
|
|
383
477
|
@staticmethod
|
|
384
478
|
def normalize_live_ansi_for_boundary(*, stable_ansi: str, live_ansi: str) -> str:
|
|
@@ -441,14 +535,14 @@ class MarkdownStream:
|
|
|
441
535
|
return stable_source + "\n<!-- -->"
|
|
442
536
|
return stable_source + "\n\n<!-- -->"
|
|
443
537
|
|
|
444
|
-
def _render_markdown_to_lines(self, text: str, *, apply_mark: bool) -> list[str]:
|
|
538
|
+
def _render_markdown_to_lines(self, text: str, *, apply_mark: bool) -> tuple[list[str], list[str]]:
|
|
445
539
|
"""Render markdown text to a list of lines.
|
|
446
540
|
|
|
447
541
|
Args:
|
|
448
542
|
text (str): Markdown text to render
|
|
449
543
|
|
|
450
544
|
Returns:
|
|
451
|
-
|
|
545
|
+
tuple: (lines with line endings preserved, collected local image paths)
|
|
452
546
|
"""
|
|
453
547
|
# Render the markdown to a string buffer
|
|
454
548
|
string_io = io.StringIO()
|
|
@@ -470,6 +564,8 @@ class MarkdownStream:
|
|
|
470
564
|
temp_console.print(markdown)
|
|
471
565
|
output = string_io.getvalue()
|
|
472
566
|
|
|
567
|
+
collected_images = getattr(markdown, "collected_images", [])
|
|
568
|
+
|
|
473
569
|
# Split rendered output into lines, strip trailing spaces, and apply left margin.
|
|
474
570
|
lines = output.splitlines(keepends=True)
|
|
475
571
|
indent_prefix = " " * self.left_margin if self.left_margin > 0 else ""
|
|
@@ -503,7 +599,7 @@ class MarkdownStream:
|
|
|
503
599
|
stripped += "\n"
|
|
504
600
|
processed_lines.append(stripped)
|
|
505
601
|
|
|
506
|
-
return processed_lines
|
|
602
|
+
return processed_lines, list(collected_images)
|
|
507
603
|
|
|
508
604
|
def __del__(self) -> None:
|
|
509
605
|
"""Destructor to ensure Live display is properly cleaned up."""
|
|
@@ -531,15 +627,22 @@ class MarkdownStream:
|
|
|
531
627
|
start = time.time()
|
|
532
628
|
|
|
533
629
|
stable_chunk_to_print: str | None = None
|
|
630
|
+
new_images: list[str] = []
|
|
534
631
|
stable_changed = final or stable_line > self._stable_source_line_count
|
|
535
632
|
if stable_changed and stable_source:
|
|
536
|
-
stable_ansi = self.render_stable_ansi(
|
|
633
|
+
stable_ansi, collected_images = self.render_stable_ansi(
|
|
634
|
+
stable_source, has_live_suffix=bool(live_source), final=final
|
|
635
|
+
)
|
|
537
636
|
stable_lines = stable_ansi.splitlines(keepends=True)
|
|
538
637
|
new_lines = stable_lines[len(self._stable_rendered_lines) :]
|
|
539
638
|
if new_lines:
|
|
540
639
|
stable_chunk_to_print = "".join(new_lines)
|
|
541
640
|
self._stable_rendered_lines = stable_lines
|
|
542
641
|
self._stable_source_line_count = stable_line
|
|
642
|
+
for img in collected_images:
|
|
643
|
+
if img not in self._displayed_images:
|
|
644
|
+
new_images.append(img)
|
|
645
|
+
self._displayed_images.add(img)
|
|
543
646
|
elif final and not stable_source:
|
|
544
647
|
self._stable_rendered_lines = []
|
|
545
648
|
self._stable_source_line_count = stable_line
|
|
@@ -547,7 +650,7 @@ class MarkdownStream:
|
|
|
547
650
|
live_text_to_set: Text | None = None
|
|
548
651
|
if not final and MARKDOWN_STREAM_LIVE_REPAINT_ENABLED and self._live_sink is not None:
|
|
549
652
|
apply_mark_live = self._stable_source_line_count == 0
|
|
550
|
-
live_lines = self._render_markdown_to_lines(live_source, apply_mark=apply_mark_live)
|
|
653
|
+
live_lines, _ = self._render_markdown_to_lines(live_source, apply_mark=apply_mark_live)
|
|
551
654
|
|
|
552
655
|
if self._stable_rendered_lines:
|
|
553
656
|
stable_trailing_blank = 0
|
|
@@ -573,6 +676,10 @@ class MarkdownStream:
|
|
|
573
676
|
if stable_chunk_to_print:
|
|
574
677
|
self.console.print(Text.from_ansi(stable_chunk_to_print), end="\n")
|
|
575
678
|
|
|
679
|
+
if new_images and self._image_callback:
|
|
680
|
+
for img_path in new_images:
|
|
681
|
+
self._image_callback(img_path)
|
|
682
|
+
|
|
576
683
|
if final:
|
|
577
684
|
if self._live_sink is not None:
|
|
578
685
|
self._live_sink(None)
|
|
@@ -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,
|