klaude-code 1.4.3__py3-none-any.whl → 1.6.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/cli/main.py +22 -11
- klaude_code/cli/runtime.py +171 -34
- klaude_code/command/__init__.py +4 -0
- klaude_code/command/fork_session_cmd.py +220 -2
- klaude_code/command/help_cmd.py +2 -1
- klaude_code/command/model_cmd.py +3 -5
- klaude_code/command/model_select.py +84 -0
- klaude_code/command/refresh_cmd.py +4 -4
- klaude_code/command/registry.py +23 -0
- klaude_code/command/resume_cmd.py +62 -2
- klaude_code/command/thinking_cmd.py +30 -199
- klaude_code/config/select_model.py +47 -97
- klaude_code/config/thinking.py +255 -0
- klaude_code/core/executor.py +53 -63
- klaude_code/llm/usage.py +1 -1
- klaude_code/protocol/commands.py +11 -0
- klaude_code/protocol/op.py +15 -0
- klaude_code/session/__init__.py +2 -2
- klaude_code/session/selector.py +65 -65
- klaude_code/session/session.py +18 -12
- klaude_code/ui/modes/repl/completers.py +27 -15
- klaude_code/ui/modes/repl/event_handler.py +24 -33
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +393 -57
- klaude_code/ui/modes/repl/key_bindings.py +30 -10
- klaude_code/ui/modes/repl/renderer.py +1 -1
- klaude_code/ui/renderers/developer.py +2 -2
- klaude_code/ui/renderers/metadata.py +11 -6
- klaude_code/ui/renderers/user_input.py +18 -1
- klaude_code/ui/rich/markdown.py +41 -9
- klaude_code/ui/rich/status.py +83 -22
- klaude_code/ui/rich/theme.py +2 -2
- klaude_code/ui/terminal/notifier.py +42 -0
- klaude_code/ui/terminal/selector.py +488 -136
- {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/METADATA +1 -1
- {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/RECORD +37 -35
- {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/WHEEL +0 -0
- {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/entry_points.txt +0 -0
klaude_code/protocol/op.py
CHANGED
|
@@ -13,6 +13,7 @@ from uuid import uuid4
|
|
|
13
13
|
|
|
14
14
|
from pydantic import BaseModel, Field
|
|
15
15
|
|
|
16
|
+
from klaude_code.protocol.llm_param import Thinking
|
|
16
17
|
from klaude_code.protocol.model import UserInputPayload
|
|
17
18
|
|
|
18
19
|
if TYPE_CHECKING:
|
|
@@ -75,6 +76,17 @@ class ChangeModelOperation(Operation):
|
|
|
75
76
|
session_id: str
|
|
76
77
|
model_name: str
|
|
77
78
|
save_as_default: bool = False
|
|
79
|
+
# When True, the executor must not auto-trigger an interactive thinking selector.
|
|
80
|
+
# This is required for in-prompt model switching where the terminal is already
|
|
81
|
+
# controlled by a prompt_toolkit PromptSession.
|
|
82
|
+
defer_thinking_selection: bool = False
|
|
83
|
+
# When False, do not emit WelcomeEvent (which renders a banner/panel).
|
|
84
|
+
# This is useful for in-prompt model switching where extra output is noisy.
|
|
85
|
+
emit_welcome_event: bool = True
|
|
86
|
+
|
|
87
|
+
# When False, do not emit the "Switched to: ..." developer message.
|
|
88
|
+
# This is useful for in-prompt model switching where extra output is noisy.
|
|
89
|
+
emit_switch_message: bool = True
|
|
78
90
|
|
|
79
91
|
async def execute(self, handler: OperationHandler) -> None:
|
|
80
92
|
await handler.handle_change_model(self)
|
|
@@ -85,6 +97,9 @@ class ChangeThinkingOperation(Operation):
|
|
|
85
97
|
|
|
86
98
|
type: OperationType = OperationType.CHANGE_THINKING
|
|
87
99
|
session_id: str
|
|
100
|
+
thinking: Thinking | None = None
|
|
101
|
+
emit_welcome_event: bool = True
|
|
102
|
+
emit_switch_message: bool = True
|
|
88
103
|
|
|
89
104
|
async def execute(self, handler: OperationHandler) -> None:
|
|
90
105
|
await handler.handle_change_thinking(self)
|
klaude_code/session/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from .selector import
|
|
1
|
+
from .selector import SessionSelectOption, build_session_select_options, format_user_messages_display
|
|
2
2
|
from .session import Session
|
|
3
3
|
|
|
4
|
-
__all__ = ["Session", "
|
|
4
|
+
__all__ = ["Session", "SessionSelectOption", "build_session_select_options", "format_user_messages_display"]
|
klaude_code/session/selector.py
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import time
|
|
2
|
-
|
|
3
|
-
from klaude_code.trace import log, log_debug
|
|
4
|
-
from klaude_code.ui.terminal.selector import SelectItem, select_one
|
|
2
|
+
from dataclasses import dataclass
|
|
5
3
|
|
|
6
4
|
from .session import Session
|
|
7
5
|
|
|
@@ -30,69 +28,71 @@ def _relative_time(ts: float) -> str:
|
|
|
30
28
|
return f"{months} month{'s' if months != 1 else ''} ago"
|
|
31
29
|
|
|
32
30
|
|
|
33
|
-
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class SessionSelectOption:
|
|
33
|
+
"""Option data for session selection UI."""
|
|
34
|
+
|
|
35
|
+
session_id: str
|
|
36
|
+
user_messages: list[str]
|
|
37
|
+
messages_count: str
|
|
38
|
+
relative_time: str
|
|
39
|
+
model_name: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _format_message(msg: str) -> str:
|
|
43
|
+
"""Format a user message for display (strip and collapse newlines)."""
|
|
44
|
+
return msg.strip().replace("\n", " ")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def format_user_messages_display(messages: list[str]) -> list[str]:
|
|
48
|
+
"""Format user messages for display in session selection.
|
|
49
|
+
|
|
50
|
+
Shows up to 6 messages. If more than 6, shows first 3 and last 3 with ellipsis.
|
|
51
|
+
Each message is on its own line.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
messages: List of user messages.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
List of formatted message lines for display.
|
|
58
|
+
"""
|
|
59
|
+
if len(messages) <= 6:
|
|
60
|
+
return messages
|
|
61
|
+
|
|
62
|
+
# More than 6: show first 3, ellipsis, last 3
|
|
63
|
+
result = messages[:3]
|
|
64
|
+
result.append("⋮")
|
|
65
|
+
result.extend(messages[-3:])
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_session_select_options() -> list[SessionSelectOption]:
|
|
70
|
+
"""Build session selection options data.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
List of SessionSelectOption, or empty list if no sessions exist.
|
|
74
|
+
"""
|
|
34
75
|
sessions = Session.list_sessions()
|
|
35
76
|
if not sessions:
|
|
36
|
-
|
|
37
|
-
return None
|
|
38
|
-
|
|
39
|
-
try:
|
|
40
|
-
from prompt_toolkit.styles import Style
|
|
41
|
-
|
|
42
|
-
items: list[SelectItem[str]] = []
|
|
43
|
-
for s in sessions:
|
|
44
|
-
first_msg = s.first_user_message or "N/A"
|
|
45
|
-
first_msg = first_msg.strip().replace("\n", " ")
|
|
46
|
-
|
|
47
|
-
msg_count = "N/A" if s.messages_count == -1 else f"{s.messages_count} messages"
|
|
48
|
-
model = s.model_name or "N/A"
|
|
49
|
-
|
|
50
|
-
title = [
|
|
51
|
-
("class:msg", f"{first_msg}\n"),
|
|
52
|
-
("class:meta", f" {msg_count} · {_relative_time(s.updated_at)} · {model} · {s.id}\n\n"),
|
|
53
|
-
]
|
|
54
|
-
items.append(
|
|
55
|
-
SelectItem(
|
|
56
|
-
title=title,
|
|
57
|
-
value=str(s.id),
|
|
58
|
-
search_text=f"{first_msg} {model} {s.id}",
|
|
59
|
-
)
|
|
60
|
-
)
|
|
77
|
+
return []
|
|
61
78
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
+
options: list[SessionSelectOption] = []
|
|
80
|
+
for s in sessions:
|
|
81
|
+
user_messages = [_format_message(m) for m in s.user_messages if m.strip()]
|
|
82
|
+
if not user_messages:
|
|
83
|
+
user_messages = ["N/A"]
|
|
84
|
+
|
|
85
|
+
msg_count = "N/A" if s.messages_count == -1 else f"{s.messages_count} messages"
|
|
86
|
+
model = s.model_name or "N/A"
|
|
87
|
+
|
|
88
|
+
options.append(
|
|
89
|
+
SessionSelectOption(
|
|
90
|
+
session_id=str(s.id),
|
|
91
|
+
user_messages=user_messages,
|
|
92
|
+
messages_count=msg_count,
|
|
93
|
+
relative_time=_relative_time(s.updated_at),
|
|
94
|
+
model_name=model,
|
|
95
|
+
)
|
|
79
96
|
)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
for i, s in enumerate(sessions, 1):
|
|
84
|
-
first_msg = (s.first_user_message or "N/A").strip().replace("\n", " ")
|
|
85
|
-
if len(first_msg) > 60:
|
|
86
|
-
first_msg = first_msg[:59] + "…"
|
|
87
|
-
msg_count = "N/A" if s.messages_count == -1 else f"{s.messages_count} msgs"
|
|
88
|
-
model = s.model_name or "N/A"
|
|
89
|
-
print(f"{i}. {first_msg}")
|
|
90
|
-
print(f" {_relative_time(s.updated_at)} · {msg_count} · {model}")
|
|
91
|
-
try:
|
|
92
|
-
raw = input("Select a session number: ").strip()
|
|
93
|
-
idx = int(raw)
|
|
94
|
-
if 1 <= idx <= len(sessions):
|
|
95
|
-
return str(sessions[idx - 1].id)
|
|
96
|
-
except (ValueError, EOFError):
|
|
97
|
-
return None
|
|
98
|
-
return None
|
|
97
|
+
|
|
98
|
+
return options
|
klaude_code/session/session.py
CHANGED
|
@@ -197,11 +197,16 @@ class Session(BaseModel):
|
|
|
197
197
|
)
|
|
198
198
|
self._store.append_and_flush(session_id=self.id, items=items, meta=meta)
|
|
199
199
|
|
|
200
|
-
def fork(self, *, new_id: str | None = None) -> Session:
|
|
200
|
+
def fork(self, *, new_id: str | None = None, until_index: int | None = None) -> Session:
|
|
201
201
|
"""Create a new session as a fork of the current session.
|
|
202
202
|
|
|
203
203
|
The forked session copies metadata and conversation history, but does not
|
|
204
204
|
modify the current session.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
new_id: Optional ID for the forked session.
|
|
208
|
+
until_index: If provided, only copy conversation history up to (but not including) this index.
|
|
209
|
+
If None, copy all history.
|
|
205
210
|
"""
|
|
206
211
|
|
|
207
212
|
forked = Session.create(id=new_id, work_dir=self.work_dir)
|
|
@@ -213,7 +218,8 @@ class Session(BaseModel):
|
|
|
213
218
|
forked.file_tracker = {k: v.model_copy(deep=True) for k, v in self.file_tracker.items()}
|
|
214
219
|
forked.todos = [todo.model_copy(deep=True) for todo in self.todos]
|
|
215
220
|
|
|
216
|
-
|
|
221
|
+
history_to_copy = self.conversation_history[:until_index] if until_index is not None else self.conversation_history
|
|
222
|
+
items = [it.model_copy(deep=True) for it in history_to_copy]
|
|
217
223
|
if items:
|
|
218
224
|
forked.append_history(items)
|
|
219
225
|
|
|
@@ -338,7 +344,7 @@ class Session(BaseModel):
|
|
|
338
344
|
updated_at: float
|
|
339
345
|
work_dir: str
|
|
340
346
|
path: str
|
|
341
|
-
|
|
347
|
+
user_messages: list[str] = []
|
|
342
348
|
messages_count: int = -1
|
|
343
349
|
model_name: str | None = None
|
|
344
350
|
|
|
@@ -346,10 +352,11 @@ class Session(BaseModel):
|
|
|
346
352
|
def list_sessions(cls) -> list[SessionMetaBrief]:
|
|
347
353
|
store = get_default_store()
|
|
348
354
|
|
|
349
|
-
def
|
|
355
|
+
def _get_user_messages(session_id: str) -> list[str]:
|
|
350
356
|
events_path = store.paths.events_file(session_id)
|
|
351
357
|
if not events_path.exists():
|
|
352
|
-
return
|
|
358
|
+
return []
|
|
359
|
+
messages: list[str] = []
|
|
353
360
|
try:
|
|
354
361
|
for line in events_path.read_text(encoding="utf-8").splitlines():
|
|
355
362
|
obj_raw = json.loads(line)
|
|
@@ -360,15 +367,14 @@ class Session(BaseModel):
|
|
|
360
367
|
continue
|
|
361
368
|
data_raw = obj.get("data")
|
|
362
369
|
if not isinstance(data_raw, dict):
|
|
363
|
-
|
|
370
|
+
continue
|
|
364
371
|
data = cast(dict[str, Any], data_raw)
|
|
365
372
|
content = data.get("content")
|
|
366
373
|
if isinstance(content, str):
|
|
367
|
-
|
|
368
|
-
return None
|
|
374
|
+
messages.append(content)
|
|
369
375
|
except (OSError, json.JSONDecodeError):
|
|
370
|
-
|
|
371
|
-
return
|
|
376
|
+
pass
|
|
377
|
+
return messages
|
|
372
378
|
|
|
373
379
|
items: list[Session.SessionMetaBrief] = []
|
|
374
380
|
for meta_path in store.iter_meta_files():
|
|
@@ -382,7 +388,7 @@ class Session(BaseModel):
|
|
|
382
388
|
created = float(data.get("created_at", meta_path.stat().st_mtime))
|
|
383
389
|
updated = float(data.get("updated_at", meta_path.stat().st_mtime))
|
|
384
390
|
work_dir = str(data.get("work_dir", ""))
|
|
385
|
-
|
|
391
|
+
user_messages = _get_user_messages(sid)
|
|
386
392
|
messages_count = int(data.get("messages_count", -1))
|
|
387
393
|
model_name = data.get("model_name") if isinstance(data.get("model_name"), str) else None
|
|
388
394
|
|
|
@@ -393,7 +399,7 @@ class Session(BaseModel):
|
|
|
393
399
|
updated_at=updated,
|
|
394
400
|
work_dir=work_dir,
|
|
395
401
|
path=str(meta_path),
|
|
396
|
-
|
|
402
|
+
user_messages=user_messages,
|
|
397
403
|
messages_count=messages_count,
|
|
398
404
|
model_name=model_name,
|
|
399
405
|
)
|
|
@@ -19,7 +19,7 @@ import re
|
|
|
19
19
|
import shutil
|
|
20
20
|
import subprocess
|
|
21
21
|
import time
|
|
22
|
-
from collections.abc import Iterable
|
|
22
|
+
from collections.abc import Callable, Iterable
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
from typing import NamedTuple
|
|
25
25
|
|
|
@@ -27,7 +27,7 @@ from prompt_toolkit.completion import Completer, Completion
|
|
|
27
27
|
from prompt_toolkit.document import Document
|
|
28
28
|
from prompt_toolkit.formatted_text import FormattedText
|
|
29
29
|
|
|
30
|
-
from klaude_code.
|
|
30
|
+
from klaude_code.protocol.commands import CommandInfo
|
|
31
31
|
from klaude_code.trace.log import DebugType, log_debug
|
|
32
32
|
|
|
33
33
|
# Pattern to match @token for completion refresh (used by key bindings).
|
|
@@ -40,12 +40,18 @@ AT_TOKEN_PATTERN = re.compile(r'(^|\s)@(?P<frag>"[^"]*"|[^\s]*)$')
|
|
|
40
40
|
SKILL_TOKEN_PATTERN = re.compile(r"^[$¥](?P<frag>\S*)$")
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
def create_repl_completer(
|
|
43
|
+
def create_repl_completer(
|
|
44
|
+
command_info_provider: Callable[[], list[CommandInfo]] | None = None,
|
|
45
|
+
) -> Completer:
|
|
44
46
|
"""Create and return the combined REPL completer.
|
|
45
47
|
|
|
48
|
+
Args:
|
|
49
|
+
command_info_provider: Optional callable that returns command metadata.
|
|
50
|
+
If None, slash command completion is disabled.
|
|
51
|
+
|
|
46
52
|
Returns a completer that handles both @ file paths and / slash commands.
|
|
47
53
|
"""
|
|
48
|
-
return _ComboCompleter()
|
|
54
|
+
return _ComboCompleter(command_info_provider=command_info_provider)
|
|
49
55
|
|
|
50
56
|
|
|
51
57
|
class _CmdResult(NamedTuple):
|
|
@@ -66,6 +72,9 @@ class _SlashCommandCompleter(Completer):
|
|
|
66
72
|
|
|
67
73
|
_SLASH_TOKEN_RE = re.compile(r"^/(?P<frag>\S*)$")
|
|
68
74
|
|
|
75
|
+
def __init__(self, command_info_provider: Callable[[], list[CommandInfo]] | None = None) -> None:
|
|
76
|
+
self._command_info_provider = command_info_provider
|
|
77
|
+
|
|
69
78
|
def get_completions(
|
|
70
79
|
self,
|
|
71
80
|
document: Document,
|
|
@@ -75,6 +84,9 @@ class _SlashCommandCompleter(Completer):
|
|
|
75
84
|
if document.cursor_position_row != 0:
|
|
76
85
|
return
|
|
77
86
|
|
|
87
|
+
if self._command_info_provider is None:
|
|
88
|
+
return
|
|
89
|
+
|
|
78
90
|
text_before = document.current_line_before_cursor
|
|
79
91
|
m = self._SLASH_TOKEN_RE.search(text_before)
|
|
80
92
|
if not m:
|
|
@@ -84,20 +96,20 @@ class _SlashCommandCompleter(Completer):
|
|
|
84
96
|
token_start = len(text_before) - len(f"/{frag}")
|
|
85
97
|
start_position = token_start - len(text_before) # negative offset
|
|
86
98
|
|
|
87
|
-
# Get available commands
|
|
88
|
-
|
|
99
|
+
# Get available commands from provider
|
|
100
|
+
command_infos = self._command_info_provider()
|
|
89
101
|
|
|
90
102
|
# Filter commands that match the fragment (preserve registration order)
|
|
91
|
-
matched: list[tuple[str,
|
|
92
|
-
for
|
|
93
|
-
if
|
|
94
|
-
hint = f" [{
|
|
95
|
-
matched.append((
|
|
103
|
+
matched: list[tuple[str, CommandInfo, str]] = []
|
|
104
|
+
for cmd_info in command_infos:
|
|
105
|
+
if cmd_info.name.startswith(frag):
|
|
106
|
+
hint = f" [{cmd_info.placeholder}]" if cmd_info.support_addition_params else ""
|
|
107
|
+
matched.append((cmd_info.name, cmd_info, hint))
|
|
96
108
|
|
|
97
109
|
if not matched:
|
|
98
110
|
return
|
|
99
111
|
|
|
100
|
-
for cmd_name,
|
|
112
|
+
for cmd_name, cmd_info, hint in matched:
|
|
101
113
|
completion_text = f"/{cmd_name} "
|
|
102
114
|
# Use FormattedText to style the hint (placeholder) in bright black
|
|
103
115
|
display = FormattedText([("", cmd_name), ("ansibrightblack", hint)]) if hint else cmd_name
|
|
@@ -105,7 +117,7 @@ class _SlashCommandCompleter(Completer):
|
|
|
105
117
|
text=completion_text,
|
|
106
118
|
start_position=start_position,
|
|
107
119
|
display=display,
|
|
108
|
-
display_meta=
|
|
120
|
+
display_meta=cmd_info.summary,
|
|
109
121
|
)
|
|
110
122
|
|
|
111
123
|
def is_slash_command_context(self, document: Document) -> bool:
|
|
@@ -200,9 +212,9 @@ class _SkillCompleter(Completer):
|
|
|
200
212
|
class _ComboCompleter(Completer):
|
|
201
213
|
"""Combined completer that handles @ file paths, / slash commands, and $ skills."""
|
|
202
214
|
|
|
203
|
-
def __init__(self) -> None:
|
|
215
|
+
def __init__(self, command_info_provider: Callable[[], list[CommandInfo]] | None = None) -> None:
|
|
204
216
|
self._at_completer = _AtFilesCompleter()
|
|
205
|
-
self._slash_completer = _SlashCommandCompleter()
|
|
217
|
+
self._slash_completer = _SlashCommandCompleter(command_info_provider=command_info_provider)
|
|
206
218
|
self._skill_completer = _SkillCompleter()
|
|
207
219
|
|
|
208
220
|
def get_completions(
|
|
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
|
|
5
|
-
from rich.cells import cell_len
|
|
6
5
|
from rich.rule import Rule
|
|
7
6
|
from rich.text import Text
|
|
8
7
|
|
|
@@ -15,7 +14,7 @@ from klaude_code.ui.renderers.thinking import THINKING_MESSAGE_MARK, normalize_t
|
|
|
15
14
|
from klaude_code.ui.rich import status as r_status
|
|
16
15
|
from klaude_code.ui.rich.markdown import MarkdownStream, ThinkingMarkdown
|
|
17
16
|
from klaude_code.ui.rich.theme import ThemeKey
|
|
18
|
-
from klaude_code.ui.terminal.notifier import Notification, NotificationType, TerminalNotifier
|
|
17
|
+
from klaude_code.ui.terminal.notifier import Notification, NotificationType, TerminalNotifier, emit_tmux_signal
|
|
19
18
|
from klaude_code.ui.terminal.progress_bar import OSC94States, emit_osc94
|
|
20
19
|
|
|
21
20
|
|
|
@@ -265,11 +264,27 @@ class SpinnerStatusState:
|
|
|
265
264
|
|
|
266
265
|
return result
|
|
267
266
|
|
|
268
|
-
def
|
|
269
|
-
"""Get
|
|
270
|
-
|
|
267
|
+
def get_right_text(self) -> r_status.DynamicText | None:
|
|
268
|
+
"""Get right-aligned status text (elapsed time and optional context %)."""
|
|
269
|
+
|
|
270
|
+
elapsed_text = r_status.current_elapsed_text()
|
|
271
|
+
has_context = self._context_percent is not None
|
|
272
|
+
|
|
273
|
+
if elapsed_text is None and not has_context:
|
|
271
274
|
return None
|
|
272
|
-
|
|
275
|
+
|
|
276
|
+
def _render() -> Text:
|
|
277
|
+
parts: list[str] = []
|
|
278
|
+
if self._context_percent is not None:
|
|
279
|
+
parts.append(f"{self._context_percent:.1f}%")
|
|
280
|
+
current_elapsed = r_status.current_elapsed_text()
|
|
281
|
+
if current_elapsed is not None:
|
|
282
|
+
if parts:
|
|
283
|
+
parts.append(" · ")
|
|
284
|
+
parts.append(current_elapsed)
|
|
285
|
+
return Text("".join(parts), style=ThemeKey.METADATA_DIM)
|
|
286
|
+
|
|
287
|
+
return r_status.DynamicText(_render)
|
|
273
288
|
|
|
274
289
|
|
|
275
290
|
class DisplayEventHandler:
|
|
@@ -509,6 +524,7 @@ class DisplayEventHandler:
|
|
|
509
524
|
self.spinner_status.reset()
|
|
510
525
|
self.renderer.spinner_stop()
|
|
511
526
|
self.renderer.console.print(Rule(characters="─", style=ThemeKey.LINES))
|
|
527
|
+
emit_tmux_signal() # Signal test harness if KLAUDE_TEST_SIGNAL is set
|
|
512
528
|
await self.stage_manager.transition_to(Stage.WAITING)
|
|
513
529
|
self._maybe_notify_task_finish(event)
|
|
514
530
|
|
|
@@ -549,11 +565,10 @@ class DisplayEventHandler:
|
|
|
549
565
|
def _update_spinner(self) -> None:
|
|
550
566
|
"""Update spinner text from current status state."""
|
|
551
567
|
status_text = self.spinner_status.get_status()
|
|
552
|
-
|
|
553
|
-
status_text = self._truncate_spinner_status_text(status_text, right_text=context_text)
|
|
568
|
+
right_text = self.spinner_status.get_right_text()
|
|
554
569
|
self.renderer.spinner_update(
|
|
555
570
|
status_text,
|
|
556
|
-
|
|
571
|
+
right_text,
|
|
557
572
|
)
|
|
558
573
|
|
|
559
574
|
async def _flush_assistant_buffer(self, state: StreamState) -> None:
|
|
@@ -611,27 +626,3 @@ class DisplayEventHandler:
|
|
|
611
626
|
if len(todo.content) > 0:
|
|
612
627
|
status_text = todo.content
|
|
613
628
|
return status_text.replace("\n", " ").strip()
|
|
614
|
-
|
|
615
|
-
def _truncate_spinner_status_text(self, status_text: Text, *, right_text: Text | None) -> Text:
|
|
616
|
-
"""Truncate spinner status to a single line based on terminal width.
|
|
617
|
-
|
|
618
|
-
Rich wraps based on terminal cell width (CJK chars count as 2). Use
|
|
619
|
-
cell-aware truncation to prevent the status from wrapping into two lines.
|
|
620
|
-
"""
|
|
621
|
-
|
|
622
|
-
terminal_width = self.renderer.console.size.width
|
|
623
|
-
|
|
624
|
-
# BreathingSpinner renders as a 2-column Table.grid(padding=1):
|
|
625
|
-
# 1 cell for glyph + 1 cell of padding between columns (collapsed).
|
|
626
|
-
spinner_prefix_cells = 2
|
|
627
|
-
|
|
628
|
-
hint_cells = cell_len(r_status.current_hint_text())
|
|
629
|
-
right_cells = cell_len(right_text.plain) if right_text is not None else 0
|
|
630
|
-
|
|
631
|
-
max_main_cells = terminal_width - spinner_prefix_cells - hint_cells - right_cells - 1
|
|
632
|
-
# rich.text.Text.truncate behaves unexpectedly for 0; clamp to at least 1.
|
|
633
|
-
max_main_cells = max(1, max_main_cells)
|
|
634
|
-
|
|
635
|
-
truncated = status_text.copy()
|
|
636
|
-
truncated.truncate(max_main_cells, overflow="ellipsis", pad=False)
|
|
637
|
-
return truncated
|