klaude-code 1.2.9__py3-none-any.whl → 1.2.11__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 (69) hide show
  1. klaude_code/cli/main.py +11 -5
  2. klaude_code/cli/runtime.py +21 -21
  3. klaude_code/command/__init__.py +68 -23
  4. klaude_code/command/clear_cmd.py +6 -2
  5. klaude_code/command/command_abc.py +5 -2
  6. klaude_code/command/diff_cmd.py +5 -2
  7. klaude_code/command/export_cmd.py +7 -4
  8. klaude_code/command/help_cmd.py +6 -2
  9. klaude_code/command/model_cmd.py +5 -2
  10. klaude_code/command/prompt_command.py +8 -3
  11. klaude_code/command/refresh_cmd.py +6 -2
  12. klaude_code/command/registry.py +17 -5
  13. klaude_code/command/release_notes_cmd.py +5 -2
  14. klaude_code/command/status_cmd.py +8 -4
  15. klaude_code/command/terminal_setup_cmd.py +7 -4
  16. klaude_code/const/__init__.py +1 -1
  17. klaude_code/core/agent.py +62 -9
  18. klaude_code/core/executor.py +1 -4
  19. klaude_code/core/manager/agent_manager.py +19 -14
  20. klaude_code/core/manager/llm_clients.py +47 -22
  21. klaude_code/core/manager/llm_clients_builder.py +22 -13
  22. klaude_code/core/manager/sub_agent_manager.py +1 -1
  23. klaude_code/core/prompt.py +4 -4
  24. klaude_code/core/prompts/prompt-claude-code.md +1 -12
  25. klaude_code/core/prompts/prompt-minimal.md +12 -0
  26. klaude_code/core/reminders.py +0 -3
  27. klaude_code/core/task.py +6 -2
  28. klaude_code/core/tool/file/_utils.py +30 -0
  29. klaude_code/core/tool/file/edit_tool.py +5 -30
  30. klaude_code/core/tool/file/multi_edit_tool.py +6 -31
  31. klaude_code/core/tool/file/read_tool.py +6 -18
  32. klaude_code/core/tool/file/write_tool.py +5 -30
  33. klaude_code/core/tool/memory/__init__.py +5 -0
  34. klaude_code/core/tool/memory/memory_tool.md +4 -0
  35. klaude_code/core/tool/memory/skill_loader.py +3 -2
  36. klaude_code/core/tool/memory/skill_tool.py +13 -0
  37. klaude_code/core/tool/todo/todo_write_tool.md +0 -157
  38. klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
  39. klaude_code/core/tool/tool_registry.py +3 -4
  40. klaude_code/llm/__init__.py +2 -12
  41. klaude_code/llm/anthropic/client.py +2 -1
  42. klaude_code/llm/client.py +2 -2
  43. klaude_code/llm/codex/client.py +1 -1
  44. klaude_code/llm/openai_compatible/client.py +3 -2
  45. klaude_code/llm/openrouter/client.py +3 -3
  46. klaude_code/llm/registry.py +33 -7
  47. klaude_code/llm/responses/client.py +2 -1
  48. klaude_code/llm/responses/input.py +1 -1
  49. klaude_code/llm/usage.py +17 -8
  50. klaude_code/protocol/model.py +15 -7
  51. klaude_code/protocol/op.py +5 -1
  52. klaude_code/protocol/sub_agent.py +1 -0
  53. klaude_code/session/export.py +16 -6
  54. klaude_code/session/session.py +10 -4
  55. klaude_code/session/templates/export_session.html +155 -0
  56. klaude_code/ui/core/input.py +1 -1
  57. klaude_code/ui/modes/repl/clipboard.py +5 -5
  58. klaude_code/ui/modes/repl/event_handler.py +1 -5
  59. klaude_code/ui/modes/repl/input_prompt_toolkit.py +3 -34
  60. klaude_code/ui/renderers/metadata.py +22 -1
  61. klaude_code/ui/renderers/tools.py +13 -2
  62. klaude_code/ui/rich/markdown.py +4 -1
  63. klaude_code/ui/terminal/__init__.py +55 -0
  64. klaude_code/ui/terminal/control.py +2 -2
  65. klaude_code/version.py +3 -3
  66. {klaude_code-1.2.9.dist-info → klaude_code-1.2.11.dist-info}/METADATA +1 -4
  67. {klaude_code-1.2.9.dist-info → klaude_code-1.2.11.dist-info}/RECORD +69 -66
  68. {klaude_code-1.2.9.dist-info → klaude_code-1.2.11.dist-info}/WHEEL +0 -0
  69. {klaude_code-1.2.9.dist-info → klaude_code-1.2.11.dist-info}/entry_points.txt +0 -0
@@ -46,7 +46,7 @@ class AnthropicClient(LLMClientABC):
46
46
  async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem, None]:
47
47
  param = apply_config_defaults(param, self.get_llm_config())
48
48
 
49
- metadata_tracker = MetadataTracker(cost_config=self._config.cost)
49
+ metadata_tracker = MetadataTracker(cost_config=self.get_llm_config().cost)
50
50
 
51
51
  messages = convert_history_to_input(param.input, param.model)
52
52
  tools = convert_tool_schema(param.tools)
@@ -179,6 +179,7 @@ class AnthropicClient(LLMClientABC):
179
179
  output_tokens=output_tokens,
180
180
  cached_tokens=cached_tokens,
181
181
  context_limit=param.context_limit,
182
+ max_tokens=param.max_tokens,
182
183
  )
183
184
  metadata_tracker.set_usage(usage)
184
185
  metadata_tracker.set_model_name(str(param.model))
klaude_code/llm/client.py CHANGED
@@ -19,7 +19,7 @@ class LLMClientABC(ABC):
19
19
  @abstractmethod
20
20
  async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem, None]:
21
21
  raise NotImplementedError
22
- yield cast(model.ConversationItem, None) # pyright: ignore[reportUnreachable]
22
+ yield cast(model.ConversationItem, None)
23
23
 
24
24
  def get_llm_config(self) -> llm_param.LLMConfigParameter:
25
25
  return self._config
@@ -42,7 +42,7 @@ def call_with_logged_payload(func: Callable[P, R], *args: P.args, **kwargs: P.kw
42
42
 
43
43
  payload = {k: v for k, v in kwargs.items() if v is not None}
44
44
  log_debug(
45
- json.dumps(payload, ensure_ascii=False, default=str, sort_keys=True),
45
+ json.dumps(payload, ensure_ascii=False, default=str),
46
46
  style="yellow",
47
47
  debug_type=DebugType.LLM_PAYLOAD,
48
48
  )
@@ -84,7 +84,7 @@ class CodexClient(LLMClientABC):
84
84
  # Codex API requires store=False
85
85
  param.store = False
86
86
 
87
- metadata_tracker = MetadataTracker(cost_config=self._config.cost)
87
+ metadata_tracker = MetadataTracker(cost_config=self.get_llm_config().cost)
88
88
 
89
89
  inputs = convert_history_to_input(param.input, param.model)
90
90
  tools = convert_tool_schema(param.tools)
@@ -47,7 +47,7 @@ class OpenAICompatibleClient(LLMClientABC):
47
47
  messages = convert_history_to_input(param.input, param.system, param.model)
48
48
  tools = convert_tool_schema(param.tools)
49
49
 
50
- metadata_tracker = MetadataTracker(cost_config=self._config.cost)
50
+ metadata_tracker = MetadataTracker(cost_config=self.get_llm_config().cost)
51
51
 
52
52
  extra_body = {}
53
53
  extra_headers = {"extra": json.dumps({"session_id": param.session_id}, sort_keys=True)}
@@ -88,7 +88,7 @@ class OpenAICompatibleClient(LLMClientABC):
88
88
  if (
89
89
  event.usage is not None and event.usage.completion_tokens is not None # pyright: ignore[reportUnnecessaryComparison] gcp gemini will return None usage field
90
90
  ):
91
- metadata_tracker.set_usage(convert_usage(event.usage, param.context_limit))
91
+ metadata_tracker.set_usage(convert_usage(event.usage, param.context_limit, param.max_tokens))
92
92
  if event.model:
93
93
  metadata_tracker.set_model_name(event.model)
94
94
  if provider := getattr(event, "provider", None):
@@ -104,6 +104,7 @@ class OpenAICompatibleClient(LLMClientABC):
104
104
  convert_usage(
105
105
  openai.types.CompletionUsage.model_validate(getattr(event.choices[0], "usage")),
106
106
  param.context_limit,
107
+ param.max_tokens,
107
108
  )
108
109
  )
109
110
 
@@ -38,7 +38,7 @@ class OpenRouterClient(LLMClientABC):
38
38
  messages = convert_history_to_input(param.input, param.system, param.model)
39
39
  tools = convert_tool_schema(param.tools)
40
40
 
41
- metadata_tracker = MetadataTracker(cost_config=self._config.cost)
41
+ metadata_tracker = MetadataTracker(cost_config=self.get_llm_config().cost)
42
42
 
43
43
  extra_body: dict[str, object] = {
44
44
  "usage": {"include": True} # To get the cache tokens at the end of the response
@@ -73,7 +73,7 @@ class OpenRouterClient(LLMClientABC):
73
73
  max_tokens=param.max_tokens,
74
74
  tools=tools,
75
75
  verbosity=param.verbosity,
76
- extra_body=extra_body, # pyright: ignore[reportUnknownArgumentType]
76
+ extra_body=extra_body,
77
77
  extra_headers=extra_headers, # pyright: ignore[reportUnknownArgumentType]
78
78
  )
79
79
 
@@ -100,7 +100,7 @@ class OpenRouterClient(LLMClientABC):
100
100
  if (
101
101
  event.usage is not None and event.usage.completion_tokens is not None # pyright: ignore[reportUnnecessaryComparison]
102
102
  ): # gcp gemini will return None usage field
103
- metadata_tracker.set_usage(convert_usage(event.usage, param.context_limit))
103
+ metadata_tracker.set_usage(convert_usage(event.usage, param.context_limit, param.max_tokens))
104
104
  if event.model:
105
105
  metadata_tracker.set_model_name(event.model)
106
106
  if provider := getattr(event, "provider", None):
@@ -1,22 +1,48 @@
1
- from typing import Callable, TypeVar
1
+ from typing import TYPE_CHECKING, Callable, TypeVar
2
2
 
3
- from klaude_code.llm.client import LLMClientABC
4
3
  from klaude_code.protocol import llm_param
5
4
 
6
- _REGISTRY: dict[llm_param.LLMClientProtocol, type[LLMClientABC]] = {}
5
+ if TYPE_CHECKING:
6
+ from klaude_code.llm.client import LLMClientABC
7
7
 
8
- T = TypeVar("T", bound=LLMClientABC)
8
+ _T = TypeVar("_T", bound=type["LLMClientABC"])
9
9
 
10
+ # Track which protocols have been loaded
11
+ _loaded_protocols: set[llm_param.LLMClientProtocol] = set()
12
+ _REGISTRY: dict[llm_param.LLMClientProtocol, type["LLMClientABC"]] = {}
10
13
 
11
- def register(name: llm_param.LLMClientProtocol) -> Callable[[type[T]], type[T]]:
12
- def _decorator(cls: type[T]) -> type[T]:
14
+
15
+ def _load_protocol(protocol: llm_param.LLMClientProtocol) -> None:
16
+ """Load the module for a specific protocol on demand."""
17
+ if protocol in _loaded_protocols:
18
+ return
19
+ _loaded_protocols.add(protocol)
20
+
21
+ # Import only the needed module to trigger @register decorator
22
+ if protocol == llm_param.LLMClientProtocol.ANTHROPIC:
23
+ from . import anthropic as _ # noqa: F401
24
+ elif protocol == llm_param.LLMClientProtocol.CODEX:
25
+ from . import codex as _ # noqa: F401
26
+ elif protocol == llm_param.LLMClientProtocol.OPENAI:
27
+ from . import openai_compatible as _ # noqa: F401
28
+ elif protocol == llm_param.LLMClientProtocol.OPENROUTER:
29
+ from . import openrouter as _ # noqa: F401
30
+ elif protocol == llm_param.LLMClientProtocol.RESPONSES:
31
+ from . import responses as _ # noqa: F401
32
+
33
+
34
+ def register(name: llm_param.LLMClientProtocol) -> Callable[[_T], _T]:
35
+ """Decorator to register an LLM client class for a protocol."""
36
+
37
+ def _decorator(cls: _T) -> _T:
13
38
  _REGISTRY[name] = cls
14
39
  return cls
15
40
 
16
41
  return _decorator
17
42
 
18
43
 
19
- def create_llm_client(config: llm_param.LLMConfigParameter) -> LLMClientABC:
44
+ def create_llm_client(config: llm_param.LLMConfigParameter) -> "LLMClientABC":
45
+ _load_protocol(config.protocol)
20
46
  if config.protocol not in _REGISTRY:
21
47
  raise ValueError(f"Unknown LLMClient protocol: {config.protocol}")
22
48
  return _REGISTRY[config.protocol].create(config)
@@ -102,6 +102,7 @@ async def parse_responses_stream(
102
102
  reasoning_tokens=event.response.usage.output_tokens_details.reasoning_tokens,
103
103
  total_tokens=event.response.usage.total_tokens,
104
104
  context_limit=param.context_limit,
105
+ max_tokens=param.max_tokens,
105
106
  )
106
107
  metadata_tracker.set_usage(usage)
107
108
  metadata_tracker.set_model_name(str(param.model))
@@ -159,7 +160,7 @@ class ResponsesClient(LLMClientABC):
159
160
  async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem, None]:
160
161
  param = apply_config_defaults(param, self.get_llm_config())
161
162
 
162
- metadata_tracker = MetadataTracker(cost_config=self._config.cost)
163
+ metadata_tracker = MetadataTracker(cost_config=self.get_llm_config().cost)
163
164
 
164
165
  inputs = convert_history_to_input(param.input, param.model)
165
166
  tools = convert_tool_schema(param.tools)
@@ -34,7 +34,7 @@ def _build_tool_result_item(tool: model.ToolResultItem) -> responses.ResponseInp
34
34
  "call_id": tool.call_id,
35
35
  "output": content_parts,
36
36
  }
37
- return item # type: ignore[return-value]
37
+ return item
38
38
 
39
39
 
40
40
  def convert_history_to_input(
klaude_code/llm/usage.py CHANGED
@@ -92,10 +92,14 @@ class MetadataTracker:
92
92
  return self._metadata_item
93
93
 
94
94
 
95
- def convert_usage(usage: openai.types.CompletionUsage, context_limit: int | None = None) -> model.Usage:
95
+ def convert_usage(
96
+ usage: openai.types.CompletionUsage,
97
+ context_limit: int | None = None,
98
+ max_tokens: int | None = None,
99
+ ) -> model.Usage:
96
100
  """Convert OpenAI CompletionUsage to internal Usage model.
97
101
 
98
- context_window_size is set to total_tokens from the API response,
102
+ context_token is set to total_tokens from the API response,
99
103
  representing the actual context window usage for this turn.
100
104
  """
101
105
  return model.Usage(
@@ -104,8 +108,9 @@ def convert_usage(usage: openai.types.CompletionUsage, context_limit: int | None
104
108
  reasoning_tokens=(usage.completion_tokens_details.reasoning_tokens if usage.completion_tokens_details else 0)
105
109
  or 0,
106
110
  output_tokens=usage.completion_tokens,
107
- context_window_size=usage.total_tokens,
111
+ context_token=usage.total_tokens,
108
112
  context_limit=context_limit,
113
+ max_tokens=max_tokens,
109
114
  )
110
115
 
111
116
 
@@ -114,19 +119,21 @@ def convert_anthropic_usage(
114
119
  output_tokens: int,
115
120
  cached_tokens: int,
116
121
  context_limit: int | None = None,
122
+ max_tokens: int | None = None,
117
123
  ) -> model.Usage:
118
124
  """Convert Anthropic usage data to internal Usage model.
119
125
 
120
- context_window_size is computed from input + cached + output tokens,
126
+ context_token is computed from input + cached + output tokens,
121
127
  representing the actual context window usage for this turn.
122
128
  """
123
- context_window_size = input_tokens + cached_tokens + output_tokens
129
+ context_token = input_tokens + cached_tokens + output_tokens
124
130
  return model.Usage(
125
131
  input_tokens=input_tokens,
126
132
  output_tokens=output_tokens,
127
133
  cached_tokens=cached_tokens,
128
- context_window_size=context_window_size,
134
+ context_token=context_token,
129
135
  context_limit=context_limit,
136
+ max_tokens=max_tokens,
130
137
  )
131
138
 
132
139
 
@@ -137,10 +144,11 @@ def convert_responses_usage(
137
144
  reasoning_tokens: int,
138
145
  total_tokens: int,
139
146
  context_limit: int | None = None,
147
+ max_tokens: int | None = None,
140
148
  ) -> model.Usage:
141
149
  """Convert OpenAI Responses API usage data to internal Usage model.
142
150
 
143
- context_window_size is set to total_tokens from the API response,
151
+ context_token is set to total_tokens from the API response,
144
152
  representing the actual context window usage for this turn.
145
153
  """
146
154
  return model.Usage(
@@ -148,6 +156,7 @@ def convert_responses_usage(
148
156
  output_tokens=output_tokens,
149
157
  cached_tokens=cached_tokens,
150
158
  reasoning_tokens=reasoning_tokens,
151
- context_window_size=total_tokens,
159
+ context_token=total_tokens,
152
160
  context_limit=context_limit,
161
+ max_tokens=max_tokens,
153
162
  )
@@ -4,6 +4,7 @@ from typing import Annotated, Literal
4
4
 
5
5
  from pydantic import BaseModel, ConfigDict, Field, computed_field
6
6
 
7
+ from klaude_code import const
7
8
  from klaude_code.protocol.commands import CommandName
8
9
  from klaude_code.protocol.tools import SubAgentType
9
10
 
@@ -19,8 +20,11 @@ class Usage(BaseModel):
19
20
  output_tokens: int = 0
20
21
 
21
22
  # Context window tracking
22
- context_window_size: int | None = None # Peak total_tokens seen (for context usage display)
23
+ context_token: int | None = None # Peak total_tokens seen (for context usage display)
24
+ context_delta: int | None = None # Context growth since last task (for cache ratio calculation)
25
+ last_turn_output_token: int | None = None # Context growth since last task (for cache ratio calculation)
23
26
  context_limit: int | None = None # Model's context limit
27
+ max_tokens: int | None = None # Max output tokens for this request
24
28
 
25
29
  throughput_tps: float | None = None
26
30
  first_token_latency_ms: float | None = None
@@ -31,13 +35,13 @@ class Usage(BaseModel):
31
35
  cache_read_cost: float | None = None # Cost for cached tokens
32
36
  currency: str = "USD" # Currency for cost display (USD or CNY)
33
37
 
34
- @computed_field # type: ignore[prop-decorator]
38
+ @computed_field
35
39
  @property
36
40
  def total_tokens(self) -> int:
37
41
  """Total tokens computed from input + output tokens."""
38
42
  return self.input_tokens + self.output_tokens
39
43
 
40
- @computed_field # type: ignore[prop-decorator]
44
+ @computed_field
41
45
  @property
42
46
  def total_cost(self) -> float | None:
43
47
  """Total cost computed from input + output + cache_read costs."""
@@ -45,15 +49,18 @@ class Usage(BaseModel):
45
49
  non_none = [c for c in costs if c is not None]
46
50
  return sum(non_none) if non_none else None
47
51
 
48
- @computed_field # type: ignore[prop-decorator]
52
+ @computed_field
49
53
  @property
50
54
  def context_usage_percent(self) -> float | None:
51
- """Context usage percentage computed from context_window_size / context_limit."""
55
+ """Context usage percentage computed from context_token / (context_limit - max_tokens)."""
52
56
  if self.context_limit is None or self.context_limit <= 0:
53
57
  return None
54
- if self.context_window_size is None:
58
+ if self.context_token is None:
55
59
  return None
56
- return (self.context_window_size / self.context_limit) * 100
60
+ effective_limit = self.context_limit - (self.max_tokens or const.DEFAULT_MAX_TOKENS)
61
+ if effective_limit <= 0:
62
+ return None
63
+ return (self.context_token / effective_limit) * 100
57
64
 
58
65
 
59
66
  class TodoItem(BaseModel):
@@ -314,6 +321,7 @@ class TaskMetadata(BaseModel):
314
321
  model_name: str = ""
315
322
  provider: str | None = None
316
323
  task_duration_s: float | None = None
324
+ turn_count: int = 0
317
325
 
318
326
  @staticmethod
319
327
  def aggregate_by_model(metadata_list: list["TaskMetadata"]) -> list["TaskMetadata"]:
@@ -63,7 +63,11 @@ class InterruptOperation(Operation):
63
63
 
64
64
 
65
65
  class InitAgentOperation(Operation):
66
- """Operation for initializing an agent and replaying history if any."""
66
+ """Operation for initializing an agent and replaying history if any.
67
+
68
+ If session_id is None, a new session is created with an auto-generated ID.
69
+ If session_id is provided, attempts to load existing session or creates new one.
70
+ """
67
71
 
68
72
  type: OperationType = OperationType.INIT_AGENT
69
73
  session_id: str | None = None
@@ -290,6 +290,7 @@ register_sub_agent(
290
290
  tool_set=(tools.BASH, tools.READ),
291
291
  prompt_builder=_explore_prompt_builder,
292
292
  active_form="Exploring",
293
+ target_model_filter=lambda model: ("haiku" not in model) and ("kimi" not in model) and ("grok" not in model),
293
294
  )
294
295
  )
295
296
 
@@ -194,11 +194,18 @@ def _render_single_metadata(
194
194
  input_stat += f"({_format_cost(u.input_cost, u.currency)})"
195
195
  parts.append(f'<span class="metadata-stat">{input_stat}</span>')
196
196
 
197
- # Cached with cost
197
+ # Cached with cost and cache ratio
198
198
  if u.cached_tokens > 0:
199
199
  cached_stat = f"cached: {_format_token_count(u.cached_tokens)}"
200
200
  if u.cache_read_cost is not None:
201
201
  cached_stat += f"({_format_cost(u.cache_read_cost, u.currency)})"
202
+ # Cache ratio: (cached + context_delta - last_turn_output) / input tokens
203
+ # Shows how much of the input was cached (not new context growth)
204
+ if u.input_tokens > 0:
205
+ context_delta = u.context_delta or 0
206
+ last_turn_output_token = u.last_turn_output_token or 0
207
+ cache_ratio = (u.cached_tokens + context_delta - last_turn_output_token) / u.input_tokens * 100
208
+ cached_stat += f"[{cache_ratio:.0f}%]"
202
209
  parts.append(f'<span class="metadata-stat">{cached_stat}</span>')
203
210
 
204
211
  # Output with cost
@@ -294,7 +301,7 @@ def _try_render_todo_args(arguments: str) -> str | None:
294
301
  return None
295
302
 
296
303
  return f'<div class="todo-list">{"".join(items_html)}</div>'
297
- except Exception:
304
+ except (json.JSONDecodeError, KeyError, TypeError):
298
305
  return None
299
306
 
300
307
 
@@ -380,7 +387,7 @@ def _get_mermaid_link_html(
380
387
  try:
381
388
  args = json.loads(tool_call.arguments)
382
389
  code = args.get("code", "")
383
- except Exception:
390
+ except (json.JSONDecodeError, TypeError):
384
391
  code = ""
385
392
  else:
386
393
  code = ""
@@ -403,6 +410,9 @@ def _get_mermaid_link_html(
403
410
  buttons_html.append(
404
411
  f'<button type="button" class="copy-mermaid-btn" data-code="{escaped_code}" title="Copy Mermaid Code">Copy Code</button>'
405
412
  )
413
+ buttons_html.append(
414
+ '<button type="button" class="fullscreen-mermaid-btn" title="View Fullscreen">Fullscreen</button>'
415
+ )
406
416
 
407
417
  link = ui_extra.link if isinstance(ui_extra, model.MermaidLinkUIExtra) else None
408
418
 
@@ -447,7 +457,7 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
447
457
  try:
448
458
  parsed = json.loads(tool_call.arguments)
449
459
  args_text = json.dumps(parsed, ensure_ascii=False, indent=2)
450
- except Exception:
460
+ except (json.JSONDecodeError, TypeError):
451
461
  args_text = tool_call.arguments
452
462
 
453
463
  args_html = _escape_html(args_text or "")
@@ -469,7 +479,7 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
469
479
  parsed_args = json.loads(tool_call.arguments)
470
480
  if parsed_args.get("command") in {"create", "str_replace", "insert"}:
471
481
  force_collapse = True
472
- except Exception:
482
+ except (json.JSONDecodeError, TypeError):
473
483
  pass
474
484
 
475
485
  should_collapse = force_collapse or _should_collapse(args_html)
@@ -506,7 +516,7 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
506
516
  new_string = args_data.get("new_string", "")
507
517
  if old_string == "" and new_string:
508
518
  diff_text = "\n".join(f"+{line}" for line in new_string.splitlines())
509
- except Exception:
519
+ except (json.JSONDecodeError, TypeError):
510
520
  pass
511
521
 
512
522
  items_to_render: list[str] = []
@@ -102,8 +102,14 @@ class Session(BaseModel):
102
102
  prefix = time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime(self.created_at))
103
103
  return self._messages_dir() / f"{prefix}-{self.id}.jsonl"
104
104
 
105
+ @classmethod
106
+ def create(cls, id: str | None = None) -> "Session":
107
+ """Create a new session without checking for existing files."""
108
+ return Session(id=id or uuid.uuid4().hex, work_dir=Path.cwd())
109
+
105
110
  @classmethod
106
111
  def load(cls, id: str) -> "Session":
112
+ """Load an existing session or create a new one if not found."""
107
113
  # Load session metadata
108
114
  sessions_dir = cls._sessions_dir()
109
115
  session_candidates = sorted(
@@ -167,7 +173,7 @@ class Session(BaseModel):
167
173
  item = cls_type(**data)
168
174
  # pyright: ignore[reportAssignmentType]
169
175
  history.append(item) # type: ignore[arg-type]
170
- except Exception:
176
+ except (json.JSONDecodeError, KeyError, TypeError):
171
177
  # Best-effort load; skip malformed lines
172
178
  continue
173
179
  sess.conversation_history = history
@@ -242,7 +248,7 @@ class Session(BaseModel):
242
248
  if ts > latest_ts:
243
249
  latest_ts = ts
244
250
  latest_id = sid
245
- except Exception:
251
+ except (json.JSONDecodeError, KeyError, TypeError, OSError):
246
252
  continue
247
253
  return latest_id
248
254
 
@@ -395,7 +401,7 @@ class Session(BaseModel):
395
401
  text_parts.append(text)
396
402
  return " ".join(text_parts) if text_parts else None
397
403
  return None
398
- except Exception:
404
+ except (json.JSONDecodeError, KeyError, TypeError, OSError):
399
405
  return None
400
406
  return None
401
407
 
@@ -403,7 +409,7 @@ class Session(BaseModel):
403
409
  for p in sessions_dir.glob("*.json"):
404
410
  try:
405
411
  data = json.loads(p.read_text())
406
- except Exception:
412
+ except (json.JSONDecodeError, OSError):
407
413
  # Skip unreadable files
408
414
  continue
409
415
  # Filter out sub-agent sessions
@@ -338,6 +338,57 @@
338
338
  border-color: var(--accent);
339
339
  }
340
340
 
341
+ .mermaid-modal {
342
+ position: fixed;
343
+ top: 0;
344
+ left: 0;
345
+ width: 100vw;
346
+ height: 100vh;
347
+ background: rgba(255, 255, 255, 0.98);
348
+ z-index: 1000;
349
+ display: flex;
350
+ flex-direction: column;
351
+ align-items: center;
352
+ justify-content: center;
353
+ opacity: 0;
354
+ pointer-events: none;
355
+ transition: opacity 0.2s;
356
+ }
357
+ .mermaid-modal.active {
358
+ opacity: 1;
359
+ pointer-events: auto;
360
+ }
361
+ .mermaid-modal-content {
362
+ width: 95%;
363
+ height: 90%;
364
+ display: flex;
365
+ align-items: center;
366
+ justify-content: center;
367
+ overflow: auto;
368
+ }
369
+ .mermaid-modal-content svg {
370
+ width: auto !important;
371
+ height: auto !important;
372
+ max-width: 100%;
373
+ max-height: 100%;
374
+ }
375
+ .mermaid-modal-close {
376
+ position: absolute;
377
+ top: 20px;
378
+ right: 20px;
379
+ background: transparent;
380
+ border: none;
381
+ font-size: 32px;
382
+ cursor: pointer;
383
+ color: var(--text-dim);
384
+ z-index: 1001;
385
+ line-height: 1;
386
+ padding: 8px;
387
+ }
388
+ .mermaid-modal-close:hover {
389
+ color: var(--text);
390
+ }
391
+
341
392
  .copy-mermaid-btn {
342
393
  border: 1px solid var(--border);
343
394
  background: transparent;
@@ -356,6 +407,25 @@
356
407
  border-color: var(--accent);
357
408
  }
358
409
 
410
+ .fullscreen-mermaid-btn {
411
+ margin-left: 8px;
412
+ border: 1px solid var(--border);
413
+ background: transparent;
414
+ color: var(--text-dim);
415
+ font-family: var(--font-mono);
416
+ font-size: var(--font-size-xs);
417
+ text-transform: uppercase;
418
+ padding: 2px 10px;
419
+ border-radius: 999px;
420
+ cursor: pointer;
421
+ transition: color 0.2s, border-color 0.2s, background 0.2s;
422
+ font-weight: var(--font-weight-bold);
423
+ }
424
+ .fullscreen-mermaid-btn:hover {
425
+ color: var(--text);
426
+ border-color: var(--accent);
427
+ }
428
+
359
429
  .assistant-rendered {
360
430
  width: 100%;
361
431
  }
@@ -1065,6 +1135,13 @@
1065
1135
  </svg>
1066
1136
  </div>
1067
1137
 
1138
+ <div id="mermaid-modal" class="mermaid-modal">
1139
+ <button class="mermaid-modal-close" id="mermaid-modal-close">
1140
+ &times;
1141
+ </button>
1142
+ <div class="mermaid-modal-content" id="mermaid-modal-content"></div>
1143
+ </div>
1144
+
1068
1145
  <link
1069
1146
  rel="stylesheet"
1070
1147
  href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css"
@@ -1280,6 +1357,84 @@
1280
1357
  });
1281
1358
  });
1282
1359
 
1360
+ // Mermaid Fullscreen Logic
1361
+ const modal = document.getElementById("mermaid-modal");
1362
+ const modalContent = document.getElementById("mermaid-modal-content");
1363
+ const modalClose = document.getElementById("mermaid-modal-close");
1364
+
1365
+ if (modal && modalContent && modalClose) {
1366
+ const closeModal = () => {
1367
+ modal.classList.remove("active");
1368
+ modalContent.innerHTML = "";
1369
+ };
1370
+
1371
+ modalClose.addEventListener("click", closeModal);
1372
+
1373
+ modal.addEventListener("click", (e) => {
1374
+ if (e.target === modal) {
1375
+ closeModal();
1376
+ }
1377
+ });
1378
+
1379
+ // Handle Escape key
1380
+ document.addEventListener("keydown", (e) => {
1381
+ if (e.key === "Escape" && modal.classList.contains("active")) {
1382
+ closeModal();
1383
+ }
1384
+ });
1385
+
1386
+ document.querySelectorAll(".fullscreen-mermaid-btn").forEach((btn) => {
1387
+ btn.addEventListener("click", (e) => {
1388
+ // The structure is:
1389
+ // wrapper > mermaid > svg
1390
+ // wrapper > toolbar > buttons > btn
1391
+
1392
+ // We need to find the mermaid div that is a sibling of the toolbar
1393
+
1394
+ // Traverse up to the wrapper
1395
+ let wrapper = btn.closest("div[style*='background: white']");
1396
+
1397
+ if (!wrapper) {
1398
+ // Fallback: try to find by traversing up and looking for .mermaid
1399
+ let p = btn.parentElement;
1400
+ while (p) {
1401
+ if (p.querySelector(".mermaid")) {
1402
+ wrapper = p;
1403
+ break;
1404
+ }
1405
+ p = p.parentElement;
1406
+ if (p === document.body) break;
1407
+ }
1408
+ }
1409
+
1410
+ if (wrapper) {
1411
+ const mermaidDiv = wrapper.querySelector(".mermaid");
1412
+ if (mermaidDiv) {
1413
+ const svg = mermaidDiv.querySelector("svg");
1414
+
1415
+ if (svg) {
1416
+ // Clone the SVG to put in modal
1417
+ // We treat the SVG as the source
1418
+ const clone = svg.cloneNode(true);
1419
+ // Remove fixed sizes to let it scale in flex container
1420
+ clone.removeAttribute("height");
1421
+ clone.removeAttribute("width");
1422
+ clone.style.maxWidth = "100%";
1423
+ clone.style.maxHeight = "100%";
1424
+
1425
+ modalContent.appendChild(clone);
1426
+ modal.classList.add("active");
1427
+ } else if (mermaidDiv.textContent.trim()) {
1428
+ // Fallback if not rendered yet (should not happen on export usually)
1429
+ modalContent.textContent = "Diagram not rendered yet.";
1430
+ modal.classList.add("active");
1431
+ }
1432
+ }
1433
+ }
1434
+ });
1435
+ });
1436
+ }
1437
+
1283
1438
  // Scroll to bottom button
1284
1439
  const scrollBtn = document.getElementById("scroll-btn");
1285
1440
 
@@ -68,4 +68,4 @@ class InputProviderABC(ABC):
68
68
  UserInputPayload with text and optional images.
69
69
  """
70
70
  raise NotImplementedError
71
- yield UserInputPayload(text="") # pyright: ignore[reportUnreachable]
71
+ yield UserInputPayload(text="")