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
|
@@ -12,6 +12,7 @@ from collections.abc import Callable
|
|
|
12
12
|
from typing import cast
|
|
13
13
|
|
|
14
14
|
from prompt_toolkit.buffer import Buffer
|
|
15
|
+
from prompt_toolkit.filters import Always, Filter
|
|
15
16
|
from prompt_toolkit.filters.app import has_completions
|
|
16
17
|
from prompt_toolkit.key_binding import KeyBindings
|
|
17
18
|
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
|
|
@@ -21,6 +22,10 @@ def create_key_bindings(
|
|
|
21
22
|
capture_clipboard_tag: Callable[[], str | None],
|
|
22
23
|
copy_to_clipboard: Callable[[str], None],
|
|
23
24
|
at_token_pattern: re.Pattern[str],
|
|
25
|
+
*,
|
|
26
|
+
input_enabled: Filter | None = None,
|
|
27
|
+
open_model_picker: Callable[[], None] | None = None,
|
|
28
|
+
open_thinking_picker: Callable[[], None] | None = None,
|
|
24
29
|
) -> KeyBindings:
|
|
25
30
|
"""Create REPL key bindings with injected dependencies.
|
|
26
31
|
|
|
@@ -33,6 +38,7 @@ def create_key_bindings(
|
|
|
33
38
|
KeyBindings instance with all REPL handlers configured
|
|
34
39
|
"""
|
|
35
40
|
kb = KeyBindings()
|
|
41
|
+
enabled = input_enabled if input_enabled is not None else Always()
|
|
36
42
|
|
|
37
43
|
def _should_submit_instead_of_accepting_completion(buf: Buffer) -> bool:
|
|
38
44
|
"""Return True when Enter should submit even if completions are visible.
|
|
@@ -111,7 +117,7 @@ def create_key_bindings(
|
|
|
111
117
|
buf.apply_completion(completion)
|
|
112
118
|
return True
|
|
113
119
|
|
|
114
|
-
@kb.add("c-v")
|
|
120
|
+
@kb.add("c-v", filter=enabled)
|
|
115
121
|
def _(event: KeyPressEvent) -> None:
|
|
116
122
|
"""Paste image from clipboard as [Image #N]."""
|
|
117
123
|
tag = capture_clipboard_tag()
|
|
@@ -119,7 +125,7 @@ def create_key_bindings(
|
|
|
119
125
|
with contextlib.suppress(Exception):
|
|
120
126
|
event.current_buffer.insert_text(tag) # pyright: ignore[reportUnknownMemberType]
|
|
121
127
|
|
|
122
|
-
@kb.add("enter")
|
|
128
|
+
@kb.add("enter", filter=enabled)
|
|
123
129
|
def _(event: KeyPressEvent) -> None:
|
|
124
130
|
buf = event.current_buffer
|
|
125
131
|
doc = buf.document # type: ignore
|
|
@@ -150,29 +156,29 @@ def create_key_bindings(
|
|
|
150
156
|
# No need to persist manifest anymore - iter_inputs will handle image extraction
|
|
151
157
|
buf.validate_and_handle() # type: ignore
|
|
152
158
|
|
|
153
|
-
@kb.add("tab", filter=has_completions)
|
|
159
|
+
@kb.add("tab", filter=enabled & has_completions)
|
|
154
160
|
def _(event: KeyPressEvent) -> None:
|
|
155
161
|
buf = event.current_buffer
|
|
156
162
|
if _accept_current_completion(buf):
|
|
157
163
|
event.app.invalidate() # type: ignore[reportUnknownMemberType]
|
|
158
164
|
|
|
159
|
-
@kb.add("down", filter=has_completions)
|
|
165
|
+
@kb.add("down", filter=enabled & has_completions)
|
|
160
166
|
def _(event: KeyPressEvent) -> None:
|
|
161
167
|
buf = event.current_buffer
|
|
162
168
|
_cycle_completion(buf, delta=1)
|
|
163
169
|
event.app.invalidate() # type: ignore[reportUnknownMemberType]
|
|
164
170
|
|
|
165
|
-
@kb.add("up", filter=has_completions)
|
|
171
|
+
@kb.add("up", filter=enabled & has_completions)
|
|
166
172
|
def _(event: KeyPressEvent) -> None:
|
|
167
173
|
buf = event.current_buffer
|
|
168
174
|
_cycle_completion(buf, delta=-1)
|
|
169
175
|
event.app.invalidate() # type: ignore[reportUnknownMemberType]
|
|
170
176
|
|
|
171
|
-
@kb.add("c-j")
|
|
177
|
+
@kb.add("c-j", filter=enabled)
|
|
172
178
|
def _(event: KeyPressEvent) -> None:
|
|
173
179
|
event.current_buffer.insert_text("\n") # type: ignore
|
|
174
180
|
|
|
175
|
-
@kb.add("c")
|
|
181
|
+
@kb.add("c", filter=enabled)
|
|
176
182
|
def _(event: KeyPressEvent) -> None:
|
|
177
183
|
"""Copy selected text to system clipboard, or insert 'c' if no selection."""
|
|
178
184
|
buf = event.current_buffer # type: ignore
|
|
@@ -187,7 +193,7 @@ def create_key_bindings(
|
|
|
187
193
|
else:
|
|
188
194
|
buf.insert_text("c") # type: ignore[reportUnknownMemberType]
|
|
189
195
|
|
|
190
|
-
@kb.add("backspace")
|
|
196
|
+
@kb.add("backspace", filter=enabled)
|
|
191
197
|
def _(event: KeyPressEvent) -> None:
|
|
192
198
|
"""Ensure completions refresh on backspace when editing an @token.
|
|
193
199
|
|
|
@@ -218,7 +224,7 @@ def create_key_bindings(
|
|
|
218
224
|
except (AttributeError, TypeError):
|
|
219
225
|
pass
|
|
220
226
|
|
|
221
|
-
@kb.add("left")
|
|
227
|
+
@kb.add("left", filter=enabled)
|
|
222
228
|
def _(event: KeyPressEvent) -> None:
|
|
223
229
|
"""Support wrapping to previous line when pressing left at column 0."""
|
|
224
230
|
buf = event.current_buffer # type: ignore
|
|
@@ -243,7 +249,7 @@ def create_key_bindings(
|
|
|
243
249
|
except (AttributeError, IndexError, TypeError):
|
|
244
250
|
pass
|
|
245
251
|
|
|
246
|
-
@kb.add("right")
|
|
252
|
+
@kb.add("right", filter=enabled)
|
|
247
253
|
def _(event: KeyPressEvent) -> None:
|
|
248
254
|
"""Support wrapping to next line when pressing right at line end."""
|
|
249
255
|
buf = event.current_buffer # type: ignore
|
|
@@ -270,4 +276,18 @@ def create_key_bindings(
|
|
|
270
276
|
except (AttributeError, IndexError, TypeError):
|
|
271
277
|
pass
|
|
272
278
|
|
|
279
|
+
@kb.add("c-l", filter=enabled, eager=True)
|
|
280
|
+
def _(event: KeyPressEvent) -> None:
|
|
281
|
+
del event
|
|
282
|
+
if open_model_picker is not None:
|
|
283
|
+
with contextlib.suppress(Exception):
|
|
284
|
+
open_model_picker()
|
|
285
|
+
|
|
286
|
+
@kb.add("c-t", filter=enabled, eager=True)
|
|
287
|
+
def _(event: KeyPressEvent) -> None:
|
|
288
|
+
del event
|
|
289
|
+
if open_thinking_picker is not None:
|
|
290
|
+
with contextlib.suppress(Exception):
|
|
291
|
+
open_thinking_picker()
|
|
292
|
+
|
|
273
293
|
return kb
|
|
@@ -283,7 +283,7 @@ class REPLRenderer:
|
|
|
283
283
|
self._spinner_visible = False
|
|
284
284
|
self._refresh_bottom_live()
|
|
285
285
|
|
|
286
|
-
def spinner_update(self, status_text: str | Text, right_text:
|
|
286
|
+
def spinner_update(self, status_text: str | Text, right_text: RenderableType | None = None) -> None:
|
|
287
287
|
"""Update the spinner status text with optional right-aligned text."""
|
|
288
288
|
self._status_text = ShimmerStatusText(status_text, right_text)
|
|
289
289
|
self._status_spinner.update(text=SingleLine(self._status_text), style=ThemeKey.STATUS_SPINNER)
|
|
@@ -161,10 +161,10 @@ def _format_cost(cost: float | None, currency: str = "USD") -> str:
|
|
|
161
161
|
def _render_fork_session_output(command_output: model.CommandOutput) -> RenderableType:
|
|
162
162
|
"""Render fork session output with usage instructions."""
|
|
163
163
|
if not isinstance(command_output.ui_extra, model.SessionIdUIExtra):
|
|
164
|
-
return Text("(no session id)", style=ThemeKey.METADATA)
|
|
164
|
+
return Padding.indent(Text("(no session id)", style=ThemeKey.METADATA), level=2)
|
|
165
165
|
|
|
166
|
-
session_id = command_output.ui_extra.session_id
|
|
167
166
|
grid = Table.grid(padding=(0, 1))
|
|
167
|
+
session_id = command_output.ui_extra.session_id
|
|
168
168
|
grid.add_column(style=ThemeKey.METADATA, overflow="fold")
|
|
169
169
|
|
|
170
170
|
grid.add_row(Text("Session forked. To continue in a new conversation:", style=ThemeKey.METADATA))
|
|
@@ -6,6 +6,7 @@ from rich.padding import Padding
|
|
|
6
6
|
from rich.panel import Panel
|
|
7
7
|
from rich.text import Text
|
|
8
8
|
|
|
9
|
+
from klaude_code import const
|
|
9
10
|
from klaude_code.protocol import events, model
|
|
10
11
|
from klaude_code.trace import is_debug_enabled
|
|
11
12
|
from klaude_code.ui.renderers.common import create_grid
|
|
@@ -95,10 +96,17 @@ def _render_task_metadata_block(
|
|
|
95
96
|
# Context (only for main agent)
|
|
96
97
|
if show_context_and_time and metadata.usage.context_usage_percent is not None:
|
|
97
98
|
context_size = format_number(metadata.usage.context_size or 0)
|
|
99
|
+
# Calculate effective limit (same as Usage.context_usage_percent)
|
|
100
|
+
effective_limit = (metadata.usage.context_limit or 0) - (
|
|
101
|
+
metadata.usage.max_tokens or const.DEFAULT_MAX_TOKENS
|
|
102
|
+
)
|
|
103
|
+
effective_limit_str = format_number(effective_limit) if effective_limit > 0 else "?"
|
|
98
104
|
parts.append(
|
|
99
105
|
Text.assemble(
|
|
100
106
|
("context ", ThemeKey.METADATA_DIM),
|
|
101
107
|
(context_size, ThemeKey.METADATA),
|
|
108
|
+
("/", ThemeKey.METADATA_DIM),
|
|
109
|
+
(effective_limit_str, ThemeKey.METADATA),
|
|
102
110
|
(f" ({metadata.usage.context_usage_percent:.1f}%)", ThemeKey.METADATA_DIM),
|
|
103
111
|
)
|
|
104
112
|
)
|
|
@@ -148,18 +156,15 @@ def _render_task_metadata_block(
|
|
|
148
156
|
|
|
149
157
|
|
|
150
158
|
def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
|
|
151
|
-
"""Render task metadata including main agent and sub-agents
|
|
159
|
+
"""Render task metadata including main agent and sub-agents."""
|
|
152
160
|
renderables: list[RenderableType] = []
|
|
153
161
|
|
|
154
162
|
renderables.append(
|
|
155
163
|
_render_task_metadata_block(e.metadata.main_agent, is_sub_agent=False, show_context_and_time=True)
|
|
156
164
|
)
|
|
157
165
|
|
|
158
|
-
#
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
# Render each aggregated model block
|
|
162
|
-
for meta in sorted_items:
|
|
166
|
+
# Render each sub-agent metadata block
|
|
167
|
+
for meta in e.metadata.sub_agent_task_metadata:
|
|
163
168
|
renderables.append(_render_task_metadata_block(meta, is_sub_agent=True, show_context_and_time=False))
|
|
164
169
|
|
|
165
170
|
return Group(*renderables)
|
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
import re
|
|
2
|
+
from collections.abc import Callable
|
|
2
3
|
|
|
3
4
|
from rich.console import Group, RenderableType
|
|
4
5
|
from rich.text import Text
|
|
5
6
|
|
|
6
|
-
from klaude_code.command import is_slash_command_name
|
|
7
7
|
from klaude_code.skill import get_available_skills
|
|
8
8
|
from klaude_code.ui.renderers.common import create_grid
|
|
9
9
|
from klaude_code.ui.rich.theme import ThemeKey
|
|
10
10
|
|
|
11
|
+
# Module-level command name checker. Set by cli/runtime.py on startup.
|
|
12
|
+
_command_name_checker: Callable[[str], bool] | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def set_command_name_checker(checker: Callable[[str], bool]) -> None:
|
|
16
|
+
"""Set the command name validation function (called from runtime layer)."""
|
|
17
|
+
global _command_name_checker
|
|
18
|
+
_command_name_checker = checker
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_slash_command_name(name: str) -> bool:
|
|
22
|
+
"""Check if name is a valid slash command using the injected checker."""
|
|
23
|
+
if _command_name_checker is None:
|
|
24
|
+
return False
|
|
25
|
+
return _command_name_checker(name)
|
|
26
|
+
|
|
27
|
+
|
|
11
28
|
# Match @-file patterns only when they appear at the beginning of the line
|
|
12
29
|
# or immediately after whitespace, to avoid treating mid-word email-like
|
|
13
30
|
# patterns such as foo@bar.com as file references.
|
klaude_code/ui/rich/markdown.py
CHANGED
|
@@ -254,18 +254,36 @@ class MarkdownStream:
|
|
|
254
254
|
live suffix separately may introduce an extra blank line that wouldn't
|
|
255
255
|
appear when rendering the full document.
|
|
256
256
|
|
|
257
|
-
This function removes
|
|
258
|
-
stable ANSI already ends with
|
|
257
|
+
This function removes *overlapping* blank lines from the live ANSI when
|
|
258
|
+
the stable ANSI already ends with one or more blank lines.
|
|
259
|
+
|
|
260
|
+
Important: don't remove *all* leading blank lines from the live suffix.
|
|
261
|
+
In some incomplete-block cases, the live render may begin with multiple
|
|
262
|
+
blank lines while the full-document render would keep one of them.
|
|
259
263
|
"""
|
|
260
264
|
|
|
261
265
|
stable_lines = stable_ansi.splitlines(keepends=True)
|
|
262
|
-
|
|
263
|
-
|
|
266
|
+
if not stable_lines:
|
|
267
|
+
return live_ansi
|
|
268
|
+
|
|
269
|
+
stable_trailing_blank = 0
|
|
270
|
+
for line in reversed(stable_lines):
|
|
271
|
+
if line.strip():
|
|
272
|
+
break
|
|
273
|
+
stable_trailing_blank += 1
|
|
274
|
+
if stable_trailing_blank <= 0:
|
|
264
275
|
return live_ansi
|
|
265
276
|
|
|
266
277
|
live_lines = live_ansi.splitlines(keepends=True)
|
|
267
|
-
|
|
268
|
-
|
|
278
|
+
live_leading_blank = 0
|
|
279
|
+
for line in live_lines:
|
|
280
|
+
if line.strip():
|
|
281
|
+
break
|
|
282
|
+
live_leading_blank += 1
|
|
283
|
+
|
|
284
|
+
drop = min(stable_trailing_blank, live_leading_blank)
|
|
285
|
+
if drop > 0:
|
|
286
|
+
live_lines = live_lines[drop:]
|
|
269
287
|
return "".join(live_lines)
|
|
270
288
|
|
|
271
289
|
def _append_nonfinal_sentinel(self, stable_source: str) -> str:
|
|
@@ -400,9 +418,23 @@ class MarkdownStream:
|
|
|
400
418
|
apply_mark_live = self._stable_source_line_count == 0
|
|
401
419
|
live_lines = self._render_markdown_to_lines(live_source, apply_mark=apply_mark_live)
|
|
402
420
|
|
|
403
|
-
if self._stable_rendered_lines
|
|
404
|
-
|
|
405
|
-
|
|
421
|
+
if self._stable_rendered_lines:
|
|
422
|
+
stable_trailing_blank = 0
|
|
423
|
+
for line in reversed(self._stable_rendered_lines):
|
|
424
|
+
if line.strip():
|
|
425
|
+
break
|
|
426
|
+
stable_trailing_blank += 1
|
|
427
|
+
|
|
428
|
+
if stable_trailing_blank > 0:
|
|
429
|
+
live_leading_blank = 0
|
|
430
|
+
for line in live_lines:
|
|
431
|
+
if line.strip():
|
|
432
|
+
break
|
|
433
|
+
live_leading_blank += 1
|
|
434
|
+
|
|
435
|
+
drop = min(stable_trailing_blank, live_leading_blank)
|
|
436
|
+
if drop > 0:
|
|
437
|
+
live_lines = live_lines[drop:]
|
|
406
438
|
|
|
407
439
|
live_text = Text.from_ansi("".join(live_lines))
|
|
408
440
|
self._live_sink(live_text)
|
klaude_code/ui/rich/status.py
CHANGED
|
@@ -4,10 +4,13 @@ import contextlib
|
|
|
4
4
|
import math
|
|
5
5
|
import random
|
|
6
6
|
import time
|
|
7
|
+
from collections.abc import Callable
|
|
7
8
|
|
|
8
9
|
import rich.status as rich_status
|
|
10
|
+
from rich.cells import cell_len
|
|
9
11
|
from rich.color import Color
|
|
10
12
|
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
|
13
|
+
from rich.measure import Measurement
|
|
11
14
|
from rich.spinner import Spinner as RichSpinner
|
|
12
15
|
from rich.style import Style
|
|
13
16
|
from rich.table import Table
|
|
@@ -80,20 +83,63 @@ def _format_elapsed_compact(seconds: float) -> str:
|
|
|
80
83
|
def current_hint_text(*, min_time_width: int = 0) -> str:
|
|
81
84
|
"""Return the full hint string shown on the status line.
|
|
82
85
|
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
The hint is the constant suffix shown after the main status text.
|
|
87
|
+
|
|
88
|
+
The elapsed task time is rendered on the right side of the status line
|
|
89
|
+
(near context usage), not inside the hint.
|
|
85
90
|
"""
|
|
86
91
|
|
|
92
|
+
# Keep the signature stable; min_time_width is intentionally ignored.
|
|
93
|
+
_ = min_time_width
|
|
94
|
+
return const.STATUS_HINT_TEXT
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def current_elapsed_text(*, min_time_width: int = 0) -> str | None:
|
|
98
|
+
"""Return the current task elapsed time text (e.g. "11s", "1m02s")."""
|
|
99
|
+
|
|
87
100
|
elapsed = _task_elapsed_seconds()
|
|
88
101
|
if elapsed is None:
|
|
89
|
-
return
|
|
102
|
+
return None
|
|
90
103
|
time_text = _format_elapsed_compact(elapsed)
|
|
91
104
|
if min_time_width > 0:
|
|
92
105
|
time_text = time_text.rjust(min_time_width)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
106
|
+
return time_text
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class DynamicText:
|
|
110
|
+
"""Renderable that materializes a Text instance at render time.
|
|
111
|
+
|
|
112
|
+
This is useful for status line elements that should refresh without
|
|
113
|
+
requiring explicit spinner_update calls (e.g. elapsed time).
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
factory: Callable[[], Text],
|
|
119
|
+
*,
|
|
120
|
+
min_width_cells: int = 0,
|
|
121
|
+
) -> None:
|
|
122
|
+
self._factory = factory
|
|
123
|
+
self.min_width_cells = min_width_cells
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def plain(self) -> str:
|
|
127
|
+
return self._factory().plain
|
|
128
|
+
|
|
129
|
+
def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement:
|
|
130
|
+
# Ensure Table/grid layout allocates a stable width for this renderable.
|
|
131
|
+
text = self._factory()
|
|
132
|
+
measured = Measurement.get(console, options, text)
|
|
133
|
+
min_width = max(measured.minimum, self.min_width_cells)
|
|
134
|
+
max_width = max(measured.maximum, self.min_width_cells)
|
|
135
|
+
|
|
136
|
+
limit = getattr(options, "max_width", options.size.width)
|
|
137
|
+
max_width = min(max_width, limit)
|
|
138
|
+
min_width = min(min_width, max_width)
|
|
139
|
+
return Measurement(min_width, max_width)
|
|
140
|
+
|
|
141
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
142
|
+
yield self._factory()
|
|
97
143
|
|
|
98
144
|
|
|
99
145
|
def _shimmer_profile(main_text: str) -> list[tuple[str, float]]:
|
|
@@ -220,7 +266,7 @@ class ShimmerStatusText:
|
|
|
220
266
|
def __init__(
|
|
221
267
|
self,
|
|
222
268
|
main_text: str | Text,
|
|
223
|
-
right_text:
|
|
269
|
+
right_text: RenderableType | None = None,
|
|
224
270
|
main_style: ThemeKey = ThemeKey.STATUS_TEXT,
|
|
225
271
|
) -> None:
|
|
226
272
|
if isinstance(main_text, Text):
|
|
@@ -234,34 +280,49 @@ class ShimmerStatusText:
|
|
|
234
280
|
self._right_text = right_text
|
|
235
281
|
|
|
236
282
|
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
237
|
-
left_text = self.
|
|
283
|
+
left_text = _StatusLeftText(main=self._main_text, hint_style=self._hint_style)
|
|
238
284
|
|
|
239
285
|
if self._right_text is None:
|
|
240
286
|
yield left_text
|
|
241
287
|
return
|
|
242
288
|
|
|
243
|
-
# Use Table.grid to create left-right aligned layout
|
|
244
|
-
table = Table.grid(expand=True)
|
|
289
|
+
# Use Table.grid to create left-right aligned layout with a stable gap.
|
|
290
|
+
table = Table.grid(expand=True, padding=(0, 1, 0, 0), collapse_padding=True, pad_edge=False)
|
|
245
291
|
table.add_column(justify="left", ratio=1)
|
|
246
292
|
table.add_column(justify="right")
|
|
247
293
|
table.add_row(left_text, self._right_text)
|
|
248
294
|
yield table
|
|
249
295
|
|
|
250
|
-
def _render_left_text(self, console: Console) -> Text:
|
|
251
|
-
"""Render the left part with shimmer effect on main text only."""
|
|
252
|
-
result = Text()
|
|
253
|
-
hint_style = console.get_style(str(self._hint_style))
|
|
254
296
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
297
|
+
class _StatusLeftText:
|
|
298
|
+
def __init__(self, *, main: Text, hint_style: ThemeKey) -> None:
|
|
299
|
+
self._main = main
|
|
300
|
+
self._hint_style = hint_style
|
|
301
|
+
|
|
302
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
303
|
+
max_width = getattr(options, "max_width", options.size.width)
|
|
304
|
+
|
|
305
|
+
# Keep the hint visually attached to the status text, while truncating only
|
|
306
|
+
# the main status segment when space is tight.
|
|
307
|
+
hint_text = Text(current_hint_text().strip("\n"), style=console.get_style(str(self._hint_style)))
|
|
308
|
+
hint_cells = cell_len(hint_text.plain)
|
|
309
|
+
|
|
310
|
+
main_text = Text()
|
|
311
|
+
for index, (ch, intensity) in enumerate(_shimmer_profile(self._main.plain)):
|
|
312
|
+
base_style = self._main.get_style_at_offset(console, index)
|
|
258
313
|
style = _shimmer_style(console, base_style, intensity)
|
|
259
|
-
|
|
314
|
+
main_text.append(ch, style=style)
|
|
260
315
|
|
|
261
|
-
#
|
|
262
|
-
|
|
316
|
+
# If the hint itself can't fit, fall back to truncating the combined text.
|
|
317
|
+
if max_width <= hint_cells:
|
|
318
|
+
combined = Text.assemble(main_text, hint_text)
|
|
319
|
+
combined.truncate(max(1, max_width), overflow="ellipsis", pad=False)
|
|
320
|
+
yield combined
|
|
321
|
+
return
|
|
263
322
|
|
|
264
|
-
|
|
323
|
+
main_budget = max_width - hint_cells
|
|
324
|
+
main_text.truncate(max(1, main_budget), overflow="ellipsis", pad=False)
|
|
325
|
+
yield Text.assemble(main_text, hint_text)
|
|
265
326
|
|
|
266
327
|
|
|
267
328
|
def spinner_name() -> str:
|
klaude_code/ui/rich/theme.py
CHANGED
|
@@ -44,7 +44,7 @@ LIGHT_PALETTE = Palette(
|
|
|
44
44
|
red="red",
|
|
45
45
|
yellow="yellow",
|
|
46
46
|
green="#00875f",
|
|
47
|
-
grey_yellow="#
|
|
47
|
+
grey_yellow="#5f9f7a",
|
|
48
48
|
cyan="cyan",
|
|
49
49
|
blue="#3078C5",
|
|
50
50
|
orange="#d77757",
|
|
@@ -77,7 +77,7 @@ DARK_PALETTE = Palette(
|
|
|
77
77
|
red="#d75f5f",
|
|
78
78
|
yellow="yellow",
|
|
79
79
|
green="#5fd787",
|
|
80
|
-
grey_yellow="#
|
|
80
|
+
grey_yellow="#8ac89a",
|
|
81
81
|
cyan="cyan",
|
|
82
82
|
blue="#00afff",
|
|
83
83
|
orange="#e6704e",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import subprocess
|
|
4
5
|
import sys
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from enum import Enum
|
|
@@ -8,6 +9,9 @@ from typing import TextIO, cast
|
|
|
8
9
|
|
|
9
10
|
from klaude_code.trace import DebugType, log_debug
|
|
10
11
|
|
|
12
|
+
# Environment variable for tmux test signal channel
|
|
13
|
+
TMUX_SIGNAL_ENV = "KLAUDE_TEST_SIGNAL"
|
|
14
|
+
|
|
11
15
|
ST = "\033\\"
|
|
12
16
|
BEL = "\a"
|
|
13
17
|
|
|
@@ -103,3 +107,41 @@ def _compact(text: str, limit: int = 160) -> str:
|
|
|
103
107
|
if len(squashed) > limit:
|
|
104
108
|
return squashed[: limit - 3] + "…"
|
|
105
109
|
return squashed
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def emit_tmux_signal(channel: str | None = None) -> bool:
|
|
113
|
+
"""Send a tmux wait-for signal when a task completes.
|
|
114
|
+
|
|
115
|
+
This enables synchronous testing by allowing external scripts to block
|
|
116
|
+
until a task finishes, eliminating the need for polling or sleep.
|
|
117
|
+
|
|
118
|
+
Usage:
|
|
119
|
+
KLAUDE_TEST_SIGNAL=done klaude # In tmux session
|
|
120
|
+
tmux wait-for done # Blocks until task completes
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
channel: Signal channel name. If None, reads from KLAUDE_TEST_SIGNAL env var.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if signal was sent successfully, False otherwise.
|
|
127
|
+
"""
|
|
128
|
+
channel = channel or os.getenv(TMUX_SIGNAL_ENV)
|
|
129
|
+
if not channel:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
# Check if we're in a tmux session
|
|
133
|
+
if not os.getenv("TMUX"):
|
|
134
|
+
log_debug("tmux signal skipped: not in tmux session", debug_type=DebugType.TERMINAL)
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
subprocess.run(
|
|
139
|
+
["tmux", "wait-for", "-S", channel],
|
|
140
|
+
check=True,
|
|
141
|
+
capture_output=True,
|
|
142
|
+
)
|
|
143
|
+
log_debug(f"tmux signal sent: {channel}", debug_type=DebugType.TERMINAL)
|
|
144
|
+
return True
|
|
145
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
146
|
+
log_debug(f"tmux signal failed: {e}", debug_type=DebugType.TERMINAL)
|
|
147
|
+
return False
|