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.
Files changed (37) hide show
  1. klaude_code/cli/main.py +22 -11
  2. klaude_code/cli/runtime.py +171 -34
  3. klaude_code/command/__init__.py +4 -0
  4. klaude_code/command/fork_session_cmd.py +220 -2
  5. klaude_code/command/help_cmd.py +2 -1
  6. klaude_code/command/model_cmd.py +3 -5
  7. klaude_code/command/model_select.py +84 -0
  8. klaude_code/command/refresh_cmd.py +4 -4
  9. klaude_code/command/registry.py +23 -0
  10. klaude_code/command/resume_cmd.py +62 -2
  11. klaude_code/command/thinking_cmd.py +30 -199
  12. klaude_code/config/select_model.py +47 -97
  13. klaude_code/config/thinking.py +255 -0
  14. klaude_code/core/executor.py +53 -63
  15. klaude_code/llm/usage.py +1 -1
  16. klaude_code/protocol/commands.py +11 -0
  17. klaude_code/protocol/op.py +15 -0
  18. klaude_code/session/__init__.py +2 -2
  19. klaude_code/session/selector.py +65 -65
  20. klaude_code/session/session.py +18 -12
  21. klaude_code/ui/modes/repl/completers.py +27 -15
  22. klaude_code/ui/modes/repl/event_handler.py +24 -33
  23. klaude_code/ui/modes/repl/input_prompt_toolkit.py +393 -57
  24. klaude_code/ui/modes/repl/key_bindings.py +30 -10
  25. klaude_code/ui/modes/repl/renderer.py +1 -1
  26. klaude_code/ui/renderers/developer.py +2 -2
  27. klaude_code/ui/renderers/metadata.py +11 -6
  28. klaude_code/ui/renderers/user_input.py +18 -1
  29. klaude_code/ui/rich/markdown.py +41 -9
  30. klaude_code/ui/rich/status.py +83 -22
  31. klaude_code/ui/rich/theme.py +2 -2
  32. klaude_code/ui/terminal/notifier.py +42 -0
  33. klaude_code/ui/terminal/selector.py +488 -136
  34. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/METADATA +1 -1
  35. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/RECORD +37 -35
  36. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/WHEEL +0 -0
  37. {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: Text | None = None) -> None:
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, aggregated by model+provider."""
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
- # Aggregate by (model_name, provider), sorted by total_cost descending
159
- sorted_items = model.TaskMetadata.aggregate_by_model(e.metadata.sub_agent_task_metadata)
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.
@@ -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 leading blank lines from the live ANSI when the
258
- stable ANSI already ends with a blank line.
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
- stable_ends_blank = bool(stable_lines) and not stable_lines[-1].strip()
263
- if not stable_ends_blank:
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
- while live_lines and not live_lines[0].strip():
268
- live_lines.pop(0)
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 and not self._stable_rendered_lines[-1].strip():
404
- while live_lines and not live_lines[0].strip():
405
- live_lines.pop(0)
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)
@@ -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
- Includes an optional elapsed time prefix (right-aligned, min width) and
84
- the constant hint suffix.
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 const.STATUS_HINT_TEXT
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
- suffix = const.STATUS_HINT_TEXT.strip()
94
- if suffix.startswith("(") and suffix.endswith(")"):
95
- suffix = suffix[1:-1]
96
- return f" ({time_text} · {suffix})"
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: Text | None = None,
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._render_left_text(console)
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
- # Apply shimmer only to main text
256
- for index, (ch, intensity) in enumerate(_shimmer_profile(self._main_text.plain)):
257
- base_style = self._main_text.get_style_at_offset(console, index)
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
- result.append(ch, style=style)
314
+ main_text.append(ch, style=style)
260
315
 
261
- # Append hint text without shimmer
262
- result.append(current_hint_text().strip("\n"), style=hint_style)
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
- return result
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:
@@ -44,7 +44,7 @@ LIGHT_PALETTE = Palette(
44
44
  red="red",
45
45
  yellow="yellow",
46
46
  green="#00875f",
47
- grey_yellow="#a89a85",
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="#c0b095",
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