klaude-code 1.2.23__py3-none-any.whl → 1.2.24__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/command/prompt-jj-describe.md +32 -0
- klaude_code/{const/__init__.py → const.py} +4 -1
- klaude_code/core/executor.py +1 -1
- klaude_code/core/tool/file/read_tool.py +23 -1
- klaude_code/core/tool/file/write_tool.py +7 -3
- klaude_code/llm/openai_compatible/client.py +29 -102
- klaude_code/llm/openai_compatible/stream.py +272 -0
- klaude_code/llm/openrouter/client.py +29 -109
- klaude_code/llm/openrouter/{reasoning_handler.py → reasoning.py} +24 -2
- klaude_code/protocol/model.py +13 -1
- klaude_code/ui/core/stage_manager.py +0 -3
- klaude_code/ui/modes/repl/event_handler.py +94 -46
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +13 -3
- klaude_code/ui/modes/repl/renderer.py +24 -17
- klaude_code/ui/renderers/assistant.py +1 -1
- klaude_code/ui/renderers/metadata.py +2 -6
- klaude_code/ui/renderers/sub_agent.py +28 -5
- klaude_code/ui/renderers/thinking.py +16 -10
- klaude_code/ui/renderers/tools.py +26 -2
- klaude_code/ui/rich/markdown.py +18 -3
- klaude_code/ui/rich/status.py +14 -6
- klaude_code/ui/rich/theme.py +63 -12
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.24.dist-info}/METADATA +1 -1
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.24.dist-info}/RECORD +26 -25
- klaude_code/llm/openai_compatible/stream_processor.py +0 -83
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.24.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.24.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from collections.abc import AsyncGenerator
|
|
3
|
-
from typing import override
|
|
3
|
+
from typing import Any, override
|
|
4
4
|
|
|
5
5
|
import httpx
|
|
6
6
|
import openai
|
|
@@ -9,13 +9,13 @@ from openai.types.chat.completion_create_params import CompletionCreateParamsStr
|
|
|
9
9
|
from klaude_code.llm.client import LLMClientABC
|
|
10
10
|
from klaude_code.llm.input_common import apply_config_defaults
|
|
11
11
|
from klaude_code.llm.openai_compatible.input import convert_tool_schema
|
|
12
|
-
from klaude_code.llm.openai_compatible.
|
|
12
|
+
from klaude_code.llm.openai_compatible.stream import parse_chat_completions_stream
|
|
13
13
|
from klaude_code.llm.openrouter.input import convert_history_to_input, is_claude_model
|
|
14
|
-
from klaude_code.llm.openrouter.
|
|
14
|
+
from klaude_code.llm.openrouter.reasoning import ReasoningStreamHandler
|
|
15
15
|
from klaude_code.llm.registry import register
|
|
16
|
-
from klaude_code.llm.usage import MetadataTracker
|
|
16
|
+
from klaude_code.llm.usage import MetadataTracker
|
|
17
17
|
from klaude_code.protocol import llm_param, model
|
|
18
|
-
from klaude_code.trace import DebugType, is_debug_enabled,
|
|
18
|
+
from klaude_code.trace import DebugType, is_debug_enabled, log_debug
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def build_payload(
|
|
@@ -96,114 +96,34 @@ class OpenRouterClient(LLMClientABC):
|
|
|
96
96
|
debug_type=DebugType.LLM_PAYLOAD,
|
|
97
97
|
)
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
try:
|
|
100
|
+
stream = await self.client.chat.completions.create(
|
|
101
|
+
**payload,
|
|
102
|
+
extra_body=extra_body,
|
|
103
|
+
extra_headers=extra_headers,
|
|
104
|
+
)
|
|
105
|
+
except (openai.OpenAIError, httpx.HTTPError) as e:
|
|
106
|
+
yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
|
|
107
|
+
yield metadata_tracker.finalize()
|
|
108
|
+
return
|
|
104
109
|
|
|
105
110
|
reasoning_handler = ReasoningStreamHandler(
|
|
106
111
|
param_model=str(param.model),
|
|
107
112
|
response_id=None,
|
|
108
113
|
)
|
|
109
114
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
state.set_response_id(event.id)
|
|
125
|
-
reasoning_handler.set_response_id(event.id)
|
|
126
|
-
yield model.StartItem(response_id=event.id)
|
|
127
|
-
if event.usage is not None:
|
|
128
|
-
metadata_tracker.set_usage(convert_usage(event.usage, param.context_limit, param.max_tokens))
|
|
129
|
-
if event.model:
|
|
130
|
-
metadata_tracker.set_model_name(event.model)
|
|
131
|
-
if provider := getattr(event, "provider", None):
|
|
132
|
-
metadata_tracker.set_provider(str(provider))
|
|
133
|
-
if len(event.choices) == 0:
|
|
134
|
-
continue
|
|
135
|
-
delta = event.choices[0].delta
|
|
136
|
-
|
|
137
|
-
# Reasoning
|
|
138
|
-
if reasoning_details := getattr(delta, "reasoning_details", None):
|
|
139
|
-
for item in reasoning_details:
|
|
140
|
-
try:
|
|
141
|
-
reasoning_detail = ReasoningDetail.model_validate(item)
|
|
142
|
-
if reasoning_detail.text or reasoning_detail.summary:
|
|
143
|
-
metadata_tracker.record_token()
|
|
144
|
-
state.stage = "reasoning"
|
|
145
|
-
# Yield delta immediately for streaming
|
|
146
|
-
if reasoning_detail.text:
|
|
147
|
-
yield model.ReasoningTextDelta(
|
|
148
|
-
content=reasoning_detail.text,
|
|
149
|
-
response_id=state.response_id,
|
|
150
|
-
)
|
|
151
|
-
if reasoning_detail.summary:
|
|
152
|
-
yield model.ReasoningTextDelta(
|
|
153
|
-
content=reasoning_detail.summary,
|
|
154
|
-
response_id=state.response_id,
|
|
155
|
-
)
|
|
156
|
-
# Keep existing handler logic for final items
|
|
157
|
-
for conversation_item in reasoning_handler.on_detail(reasoning_detail):
|
|
158
|
-
yield conversation_item
|
|
159
|
-
except Exception as e:
|
|
160
|
-
log("reasoning_details error", str(e), style="red")
|
|
161
|
-
|
|
162
|
-
# Assistant
|
|
163
|
-
if delta.content and (
|
|
164
|
-
state.stage == "assistant" or delta.content.strip()
|
|
165
|
-
): # Process all content in assistant stage, filter empty content in reasoning stage
|
|
166
|
-
metadata_tracker.record_token()
|
|
167
|
-
if state.stage == "reasoning":
|
|
168
|
-
for item in state.flush_reasoning():
|
|
169
|
-
yield item
|
|
170
|
-
state.stage = "assistant"
|
|
171
|
-
state.accumulated_content.append(delta.content)
|
|
172
|
-
yield model.AssistantMessageDelta(
|
|
173
|
-
content=delta.content,
|
|
174
|
-
response_id=state.response_id,
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
# Tool
|
|
178
|
-
if delta.tool_calls and len(delta.tool_calls) > 0:
|
|
179
|
-
metadata_tracker.record_token()
|
|
180
|
-
if state.stage == "reasoning":
|
|
181
|
-
for item in state.flush_reasoning():
|
|
182
|
-
yield item
|
|
183
|
-
elif state.stage == "assistant":
|
|
184
|
-
for item in state.flush_assistant():
|
|
185
|
-
yield item
|
|
186
|
-
state.stage = "tool"
|
|
187
|
-
# Emit ToolCallStartItem for new tool calls
|
|
188
|
-
for tc in delta.tool_calls:
|
|
189
|
-
if tc.index not in state.emitted_tool_start_indices and tc.function and tc.function.name:
|
|
190
|
-
state.emitted_tool_start_indices.add(tc.index)
|
|
191
|
-
yield model.ToolCallStartItem(
|
|
192
|
-
response_id=state.response_id,
|
|
193
|
-
call_id=tc.id or "",
|
|
194
|
-
name=tc.function.name,
|
|
195
|
-
)
|
|
196
|
-
state.accumulated_tool_calls.add(delta.tool_calls)
|
|
197
|
-
|
|
198
|
-
except (openai.OpenAIError, httpx.HTTPError) as e:
|
|
199
|
-
yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
|
|
200
|
-
|
|
201
|
-
# Finalize
|
|
202
|
-
flushed_items = state.flush_all()
|
|
203
|
-
if flushed_items:
|
|
204
|
-
metadata_tracker.record_token()
|
|
205
|
-
for item in flushed_items:
|
|
115
|
+
def on_event(event: Any) -> None:
|
|
116
|
+
log_debug(
|
|
117
|
+
event.model_dump_json(exclude_none=True),
|
|
118
|
+
style="blue",
|
|
119
|
+
debug_type=DebugType.LLM_STREAM,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async for item in parse_chat_completions_stream(
|
|
123
|
+
stream,
|
|
124
|
+
param=param,
|
|
125
|
+
metadata_tracker=metadata_tracker,
|
|
126
|
+
reasoning_handler=reasoning_handler,
|
|
127
|
+
on_event=on_event,
|
|
128
|
+
):
|
|
206
129
|
yield item
|
|
207
|
-
|
|
208
|
-
metadata_tracker.set_response_id(state.response_id)
|
|
209
|
-
yield metadata_tracker.finalize()
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from pydantic import BaseModel
|
|
2
2
|
|
|
3
|
+
from klaude_code.llm.openai_compatible.stream import ReasoningDeltaResult, ReasoningHandlerABC
|
|
3
4
|
from klaude_code.protocol import model
|
|
5
|
+
from klaude_code.trace import log
|
|
4
6
|
|
|
5
7
|
|
|
6
8
|
class ReasoningDetail(BaseModel):
|
|
@@ -16,8 +18,8 @@ class ReasoningDetail(BaseModel):
|
|
|
16
18
|
signature: str | None = None # Claude's signature
|
|
17
19
|
|
|
18
20
|
|
|
19
|
-
class ReasoningStreamHandler:
|
|
20
|
-
"""Accumulates reasoning
|
|
21
|
+
class ReasoningStreamHandler(ReasoningHandlerABC):
|
|
22
|
+
"""Accumulates OpenRouter reasoning details and emits ordered outputs."""
|
|
21
23
|
|
|
22
24
|
def __init__(
|
|
23
25
|
self,
|
|
@@ -34,6 +36,26 @@ class ReasoningStreamHandler:
|
|
|
34
36
|
"""Update the response identifier used for emitted items."""
|
|
35
37
|
self._response_id = response_id
|
|
36
38
|
|
|
39
|
+
def on_delta(self, delta: object) -> ReasoningDeltaResult:
|
|
40
|
+
"""Parse OpenRouter's reasoning_details and return ordered stream outputs."""
|
|
41
|
+
reasoning_details = getattr(delta, "reasoning_details", None)
|
|
42
|
+
if not reasoning_details:
|
|
43
|
+
return ReasoningDeltaResult(handled=False, outputs=[])
|
|
44
|
+
|
|
45
|
+
outputs: list[str | model.ConversationItem] = []
|
|
46
|
+
for item in reasoning_details:
|
|
47
|
+
try:
|
|
48
|
+
reasoning_detail = ReasoningDetail.model_validate(item)
|
|
49
|
+
if reasoning_detail.text:
|
|
50
|
+
outputs.append(reasoning_detail.text)
|
|
51
|
+
if reasoning_detail.summary:
|
|
52
|
+
outputs.append(reasoning_detail.summary)
|
|
53
|
+
outputs.extend(self.on_detail(reasoning_detail))
|
|
54
|
+
except Exception as e:
|
|
55
|
+
log("reasoning_details error", str(e), style="red")
|
|
56
|
+
|
|
57
|
+
return ReasoningDeltaResult(handled=True, outputs=outputs)
|
|
58
|
+
|
|
37
59
|
def on_detail(self, detail: ReasoningDetail) -> list[model.ConversationItem]:
|
|
38
60
|
"""Process a single reasoning detail and return streamable items."""
|
|
39
61
|
items: list[model.ConversationItem] = []
|
klaude_code/protocol/model.py
CHANGED
|
@@ -138,6 +138,12 @@ class TruncationUIExtra(BaseModel):
|
|
|
138
138
|
truncated_length: int
|
|
139
139
|
|
|
140
140
|
|
|
141
|
+
class MarkdownDocUIExtra(BaseModel):
|
|
142
|
+
type: Literal["markdown_doc"] = "markdown_doc"
|
|
143
|
+
file_path: str
|
|
144
|
+
content: str
|
|
145
|
+
|
|
146
|
+
|
|
141
147
|
class SessionStatusUIExtra(BaseModel):
|
|
142
148
|
type: Literal["session_status"] = "session_status"
|
|
143
149
|
usage: "Usage"
|
|
@@ -146,7 +152,13 @@ class SessionStatusUIExtra(BaseModel):
|
|
|
146
152
|
|
|
147
153
|
|
|
148
154
|
ToolResultUIExtra = Annotated[
|
|
149
|
-
DiffUIExtra
|
|
155
|
+
DiffUIExtra
|
|
156
|
+
| TodoListUIExtra
|
|
157
|
+
| SessionIdUIExtra
|
|
158
|
+
| MermaidLinkUIExtra
|
|
159
|
+
| TruncationUIExtra
|
|
160
|
+
| MarkdownDocUIExtra
|
|
161
|
+
| SessionStatusUIExtra,
|
|
150
162
|
Field(discriminator="type"),
|
|
151
163
|
]
|
|
152
164
|
|
|
@@ -20,12 +20,10 @@ class StageManager:
|
|
|
20
20
|
*,
|
|
21
21
|
finish_assistant: Callable[[], Awaitable[None]],
|
|
22
22
|
finish_thinking: Callable[[], Awaitable[None]],
|
|
23
|
-
on_enter_thinking: Callable[[], None],
|
|
24
23
|
):
|
|
25
24
|
self._stage = Stage.WAITING
|
|
26
25
|
self._finish_assistant = finish_assistant
|
|
27
26
|
self._finish_thinking = finish_thinking
|
|
28
|
-
self._on_enter_thinking = on_enter_thinking
|
|
29
27
|
|
|
30
28
|
@property
|
|
31
29
|
def current_stage(self) -> Stage:
|
|
@@ -41,7 +39,6 @@ class StageManager:
|
|
|
41
39
|
if self._stage == Stage.THINKING:
|
|
42
40
|
return
|
|
43
41
|
await self.transition_to(Stage.THINKING)
|
|
44
|
-
self._on_enter_thinking()
|
|
45
42
|
|
|
46
43
|
async def finish_assistant(self) -> None:
|
|
47
44
|
if self._stage != Stage.ASSISTANT:
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
|
|
5
|
+
from rich.cells import cell_len
|
|
5
6
|
from rich.rule import Rule
|
|
6
7
|
from rich.text import Text
|
|
7
8
|
|
|
@@ -10,13 +11,48 @@ from klaude_code.protocol import events
|
|
|
10
11
|
from klaude_code.ui.core.stage_manager import Stage, StageManager
|
|
11
12
|
from klaude_code.ui.modes.repl.renderer import REPLRenderer
|
|
12
13
|
from klaude_code.ui.renderers.assistant import ASSISTANT_MESSAGE_MARK
|
|
13
|
-
from klaude_code.ui.renderers.thinking import normalize_thinking_content
|
|
14
|
+
from klaude_code.ui.renderers.thinking import THINKING_MESSAGE_MARK, normalize_thinking_content
|
|
14
15
|
from klaude_code.ui.rich.markdown import MarkdownStream, ThinkingMarkdown
|
|
15
16
|
from klaude_code.ui.rich.theme import ThemeKey
|
|
16
17
|
from klaude_code.ui.terminal.notifier import Notification, NotificationType, TerminalNotifier
|
|
17
18
|
from klaude_code.ui.terminal.progress_bar import OSC94States, emit_osc94
|
|
18
19
|
|
|
19
20
|
|
|
21
|
+
def extract_last_bold_header(text: str) -> str | None:
|
|
22
|
+
"""Extract the latest complete bold header ("**...**") from text.
|
|
23
|
+
|
|
24
|
+
We treat a bold segment as a "header" only if it appears at the beginning
|
|
25
|
+
of a line (ignoring leading whitespace). This avoids picking up incidental
|
|
26
|
+
emphasis inside paragraphs.
|
|
27
|
+
|
|
28
|
+
Returns None if no complete bold segment is available yet.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
last: str | None = None
|
|
32
|
+
i = 0
|
|
33
|
+
while True:
|
|
34
|
+
start = text.find("**", i)
|
|
35
|
+
if start < 0:
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
line_start = text.rfind("\n", 0, start) + 1
|
|
39
|
+
if text[line_start:start].strip():
|
|
40
|
+
i = start + 2
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
end = text.find("**", start + 2)
|
|
44
|
+
if end < 0:
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
inner = " ".join(text[start + 2 : end].split())
|
|
48
|
+
if inner and "\n" not in inner:
|
|
49
|
+
last = inner
|
|
50
|
+
|
|
51
|
+
i = end + 2
|
|
52
|
+
|
|
53
|
+
return last
|
|
54
|
+
|
|
55
|
+
|
|
20
56
|
@dataclass
|
|
21
57
|
class ActiveStream:
|
|
22
58
|
"""Active streaming state containing buffer and markdown renderer.
|
|
@@ -117,7 +153,7 @@ class ActivityState:
|
|
|
117
153
|
for name, count in self._tool_calls.items():
|
|
118
154
|
if not first:
|
|
119
155
|
activity_text.append(", ")
|
|
120
|
-
activity_text.append(Text(name, style=ThemeKey.
|
|
156
|
+
activity_text.append(Text(name, style=ThemeKey.STATUS_TEXT_BOLD))
|
|
121
157
|
if count > 1:
|
|
122
158
|
activity_text.append(f" x {count}")
|
|
123
159
|
first = False
|
|
@@ -130,8 +166,9 @@ class ActivityState:
|
|
|
130
166
|
class SpinnerStatusState:
|
|
131
167
|
"""Multi-layer spinner status state management.
|
|
132
168
|
|
|
133
|
-
|
|
134
|
-
-
|
|
169
|
+
Layers:
|
|
170
|
+
- todo_status: Set by TodoChange (preferred when present)
|
|
171
|
+
- reasoning_status: Derived from Thinking/ThinkingDelta bold headers
|
|
135
172
|
- activity: Current activity (composing or tool_calls), mutually exclusive
|
|
136
173
|
- context_percent: Context usage percentage, updated during task execution
|
|
137
174
|
|
|
@@ -142,25 +179,31 @@ class SpinnerStatusState:
|
|
|
142
179
|
- Context percent is appended at the end if available
|
|
143
180
|
"""
|
|
144
181
|
|
|
145
|
-
DEFAULT_STATUS = "Thinking …"
|
|
146
|
-
|
|
147
182
|
def __init__(self) -> None:
|
|
148
|
-
self.
|
|
183
|
+
self._todo_status: str | None = None
|
|
184
|
+
self._reasoning_status: str | None = None
|
|
149
185
|
self._activity = ActivityState()
|
|
150
186
|
self._context_percent: float | None = None
|
|
151
187
|
|
|
152
188
|
def reset(self) -> None:
|
|
153
189
|
"""Reset all layers."""
|
|
154
|
-
self.
|
|
190
|
+
self._todo_status = None
|
|
191
|
+
self._reasoning_status = None
|
|
155
192
|
self._activity.reset()
|
|
156
193
|
self._context_percent = None
|
|
157
194
|
|
|
158
|
-
def
|
|
195
|
+
def set_todo_status(self, status: str | None) -> None:
|
|
159
196
|
"""Set base status from TodoChange."""
|
|
160
|
-
self.
|
|
197
|
+
self._todo_status = status
|
|
198
|
+
|
|
199
|
+
def set_reasoning_status(self, status: str | None) -> None:
|
|
200
|
+
"""Set reasoning-derived base status from ThinkingDelta bold headers."""
|
|
201
|
+
self._reasoning_status = status
|
|
161
202
|
|
|
162
203
|
def set_composing(self, composing: bool) -> None:
|
|
163
204
|
"""Set composing state when assistant is streaming."""
|
|
205
|
+
if composing:
|
|
206
|
+
self._reasoning_status = None
|
|
164
207
|
self._activity.set_composing(composing)
|
|
165
208
|
|
|
166
209
|
def add_tool_call(self, tool_name: str) -> None:
|
|
@@ -187,8 +230,10 @@ class SpinnerStatusState:
|
|
|
187
230
|
"""Get current spinner status as rich Text (without context)."""
|
|
188
231
|
activity_text = self._activity.get_activity_text()
|
|
189
232
|
|
|
190
|
-
|
|
191
|
-
|
|
233
|
+
base_status = self._todo_status or self._reasoning_status
|
|
234
|
+
|
|
235
|
+
if base_status:
|
|
236
|
+
result = Text(base_status, style=ThemeKey.STATUS_TEXT_BOLD)
|
|
192
237
|
if activity_text:
|
|
193
238
|
result.append(" | ")
|
|
194
239
|
result.append_text(activity_text)
|
|
@@ -196,7 +241,7 @@ class SpinnerStatusState:
|
|
|
196
241
|
activity_text.append(" …")
|
|
197
242
|
result = activity_text
|
|
198
243
|
else:
|
|
199
|
-
result = Text(
|
|
244
|
+
result = Text(const.STATUS_DEFAULT_TEXT, style=ThemeKey.STATUS_TEXT)
|
|
200
245
|
|
|
201
246
|
return result
|
|
202
247
|
|
|
@@ -220,7 +265,6 @@ class DisplayEventHandler:
|
|
|
220
265
|
self.stage_manager = StageManager(
|
|
221
266
|
finish_assistant=self._finish_assistant_stream,
|
|
222
267
|
finish_thinking=self._finish_thinking_stream,
|
|
223
|
-
on_enter_thinking=self._print_thinking_prefix,
|
|
224
268
|
)
|
|
225
269
|
|
|
226
270
|
async def consume_event(self, event: events.Event) -> None:
|
|
@@ -311,6 +355,10 @@ class DisplayEventHandler:
|
|
|
311
355
|
await self._finish_thinking_stream()
|
|
312
356
|
else:
|
|
313
357
|
# Non-streaming path (history replay or models without delta support)
|
|
358
|
+
reasoning_status = extract_last_bold_header(normalize_thinking_content(event.content))
|
|
359
|
+
if reasoning_status:
|
|
360
|
+
self.spinner_status.set_reasoning_status(reasoning_status)
|
|
361
|
+
self._update_spinner()
|
|
314
362
|
await self.stage_manager.enter_thinking_stage()
|
|
315
363
|
self.renderer.display_thinking(event.content)
|
|
316
364
|
|
|
@@ -329,6 +377,8 @@ class DisplayEventHandler:
|
|
|
329
377
|
theme=self.renderer.themes.thinking_markdown_theme,
|
|
330
378
|
console=self.renderer.console,
|
|
331
379
|
spinner=self.renderer.spinner_renderable(),
|
|
380
|
+
mark=THINKING_MESSAGE_MARK,
|
|
381
|
+
mark_style=ThemeKey.THINKING,
|
|
332
382
|
left_margin=const.MARKDOWN_LEFT_MARGIN,
|
|
333
383
|
markdown_class=ThinkingMarkdown,
|
|
334
384
|
)
|
|
@@ -337,6 +387,11 @@ class DisplayEventHandler:
|
|
|
337
387
|
|
|
338
388
|
self.thinking_stream.append(event.content)
|
|
339
389
|
|
|
390
|
+
reasoning_status = extract_last_bold_header(normalize_thinking_content(self.thinking_stream.buffer))
|
|
391
|
+
if reasoning_status:
|
|
392
|
+
self.spinner_status.set_reasoning_status(reasoning_status)
|
|
393
|
+
self._update_spinner()
|
|
394
|
+
|
|
340
395
|
if first_delta and self.thinking_stream.mdstream is not None:
|
|
341
396
|
self.thinking_stream.mdstream.update(normalize_thinking_content(self.thinking_stream.buffer))
|
|
342
397
|
|
|
@@ -415,7 +470,7 @@ class DisplayEventHandler:
|
|
|
415
470
|
|
|
416
471
|
def _on_todo_change(self, event: events.TodoChangeEvent) -> None:
|
|
417
472
|
active_form_status_text = self._extract_active_form_text(event)
|
|
418
|
-
self.spinner_status.
|
|
473
|
+
self.spinner_status.set_todo_status(active_form_status_text if active_form_status_text else None)
|
|
419
474
|
# Clear tool calls when todo changes, as the tool execution has advanced
|
|
420
475
|
self.spinner_status.clear_for_new_turn()
|
|
421
476
|
self._update_spinner()
|
|
@@ -469,14 +524,14 @@ class DisplayEventHandler:
|
|
|
469
524
|
mdstream.update(self.assistant_stream.buffer, final=True)
|
|
470
525
|
self.assistant_stream.finish()
|
|
471
526
|
|
|
472
|
-
def _print_thinking_prefix(self) -> None:
|
|
473
|
-
self.renderer.display_thinking_prefix()
|
|
474
|
-
|
|
475
527
|
def _update_spinner(self) -> None:
|
|
476
528
|
"""Update spinner text from current status state."""
|
|
529
|
+
status_text = self.spinner_status.get_status()
|
|
530
|
+
context_text = self.spinner_status.get_context_text()
|
|
531
|
+
status_text = self._truncate_spinner_status_text(status_text, right_text=context_text)
|
|
477
532
|
self.renderer.spinner_update(
|
|
478
|
-
|
|
479
|
-
|
|
533
|
+
status_text,
|
|
534
|
+
context_text,
|
|
480
535
|
)
|
|
481
536
|
|
|
482
537
|
async def _flush_assistant_buffer(self, state: StreamState) -> None:
|
|
@@ -534,35 +589,28 @@ class DisplayEventHandler:
|
|
|
534
589
|
status_text = todo.active_form
|
|
535
590
|
if len(todo.content) > 0:
|
|
536
591
|
status_text = todo.content
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
Reserve space for:
|
|
545
|
-
- Spinner glyph + space + context text: 2 chars + context text length 10 chars
|
|
546
|
-
- " | " separator: 3 chars (only if activity text present)
|
|
547
|
-
- Activity text: actual length (only if present)
|
|
548
|
-
- Status hint text (esc to interrupt)
|
|
592
|
+
return status_text.replace("\n", " ").strip()
|
|
593
|
+
|
|
594
|
+
def _truncate_spinner_status_text(self, status_text: Text, *, right_text: Text | None) -> Text:
|
|
595
|
+
"""Truncate spinner status to a single line based on terminal width.
|
|
596
|
+
|
|
597
|
+
Rich wraps based on terminal cell width (CJK chars count as 2). Use
|
|
598
|
+
cell-aware truncation to prevent the status from wrapping into two lines.
|
|
549
599
|
"""
|
|
600
|
+
|
|
550
601
|
terminal_width = self.renderer.console.size.width
|
|
551
602
|
|
|
552
|
-
#
|
|
553
|
-
|
|
603
|
+
# BreathingSpinner renders as a 2-column Table.grid(padding=1):
|
|
604
|
+
# 1 cell for glyph + 1 cell of padding between columns (collapsed).
|
|
605
|
+
spinner_prefix_cells = 2
|
|
554
606
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
if activity_text:
|
|
558
|
-
# " | " separator + actual activity text length
|
|
559
|
-
reserved_space += 3 + len(activity_text.plain)
|
|
607
|
+
hint_cells = cell_len(const.STATUS_HINT_TEXT)
|
|
608
|
+
right_cells = cell_len(right_text.plain) if right_text is not None else 0
|
|
560
609
|
|
|
561
|
-
|
|
562
|
-
|
|
610
|
+
max_main_cells = terminal_width - spinner_prefix_cells - hint_cells - right_cells - 1
|
|
611
|
+
# rich.text.Text.truncate behaves unexpectedly for 0; clamp to at least 1.
|
|
612
|
+
max_main_cells = max(1, max_main_cells)
|
|
563
613
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
truncated = text[:max_length]
|
|
568
|
-
return truncated + "…"
|
|
614
|
+
truncated = status_text.copy()
|
|
615
|
+
truncated.truncate(max_main_cells, overflow="ellipsis", pad=False)
|
|
616
|
+
return truncated
|
|
@@ -33,7 +33,9 @@ class REPLStatusSnapshot(NamedTuple):
|
|
|
33
33
|
update_message: str | None = None
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
COMPLETION_SELECTED_DARK_BG = "#8b9bff"
|
|
37
|
+
COMPLETION_SELECTED_LIGHT_BG = "#5869f7"
|
|
38
|
+
COMPLETION_SELECTED_UNKNOWN_BG = "#7080f0"
|
|
37
39
|
COMPLETION_MENU = "ansibrightblack"
|
|
38
40
|
INPUT_PROMPT_STYLE = "ansimagenta bold"
|
|
39
41
|
PLACEHOLDER_TEXT_STYLE_DARK_BG = "fg:#5a5a5a italic"
|
|
@@ -66,6 +68,14 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
66
68
|
at_token_pattern=AT_TOKEN_PATTERN,
|
|
67
69
|
)
|
|
68
70
|
|
|
71
|
+
# Select completion selected color based on terminal background
|
|
72
|
+
if self._is_light_terminal_background is True:
|
|
73
|
+
completion_selected = COMPLETION_SELECTED_LIGHT_BG
|
|
74
|
+
elif self._is_light_terminal_background is False:
|
|
75
|
+
completion_selected = COMPLETION_SELECTED_DARK_BG
|
|
76
|
+
else:
|
|
77
|
+
completion_selected = COMPLETION_SELECTED_UNKNOWN_BG
|
|
78
|
+
|
|
69
79
|
self._session: PromptSession[str] = PromptSession(
|
|
70
80
|
[(INPUT_PROMPT_STYLE, prompt)],
|
|
71
81
|
history=FileHistory(str(history_path)),
|
|
@@ -86,8 +96,8 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
86
96
|
"scrollbar.button": "bg:default",
|
|
87
97
|
"completion-menu.completion": f"bg:default fg:{COMPLETION_MENU}",
|
|
88
98
|
"completion-menu.meta.completion": f"bg:default fg:{COMPLETION_MENU}",
|
|
89
|
-
"completion-menu.completion.current": f"noreverse bg:default fg:{
|
|
90
|
-
"completion-menu.meta.completion.current": f"bg:default fg:{
|
|
99
|
+
"completion-menu.completion.current": f"noreverse bg:default fg:{completion_selected} bold",
|
|
100
|
+
"completion-menu.meta.completion.current": f"bg:default fg:{completion_selected} bold",
|
|
91
101
|
}
|
|
92
102
|
),
|
|
93
103
|
)
|