kash-shell 0.3.11__py3-none-any.whl → 0.3.12__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 (62) hide show
  1. kash/actions/core/render_as_html.py +2 -2
  2. kash/actions/core/show_webpage.py +2 -2
  3. kash/actions/core/strip_html.py +2 -2
  4. kash/commands/base/basic_file_commands.py +21 -3
  5. kash/commands/base/files_command.py +5 -4
  6. kash/commands/extras/parse_uv_lock.py +12 -3
  7. kash/commands/workspace/selection_commands.py +1 -1
  8. kash/commands/workspace/workspace_commands.py +1 -1
  9. kash/config/env_settings.py +2 -42
  10. kash/config/logger.py +30 -25
  11. kash/config/logger_basic.py +6 -6
  12. kash/config/settings.py +23 -7
  13. kash/config/setup.py +33 -5
  14. kash/config/text_styles.py +25 -22
  15. kash/embeddings/cosine.py +12 -4
  16. kash/embeddings/embeddings.py +16 -6
  17. kash/embeddings/text_similarity.py +10 -4
  18. kash/exec/__init__.py +3 -0
  19. kash/exec/action_decorators.py +4 -19
  20. kash/exec/action_exec.py +43 -23
  21. kash/exec/llm_transforms.py +2 -2
  22. kash/exec/preconditions.py +4 -12
  23. kash/exec/runtime_settings.py +134 -0
  24. kash/exec/shell_callable_action.py +5 -3
  25. kash/file_storage/file_store.py +18 -21
  26. kash/file_storage/item_file_format.py +6 -3
  27. kash/file_storage/store_filenames.py +6 -3
  28. kash/llm_utils/init_litellm.py +16 -0
  29. kash/llm_utils/llm_api_keys.py +6 -2
  30. kash/llm_utils/llm_completion.py +11 -4
  31. kash/mcp/mcp_cli.py +3 -2
  32. kash/mcp/mcp_server_routes.py +11 -12
  33. kash/media_base/transcription_deepgram.py +15 -2
  34. kash/model/__init__.py +1 -1
  35. kash/model/actions_model.py +6 -54
  36. kash/model/exec_model.py +79 -0
  37. kash/model/items_model.py +71 -50
  38. kash/model/operations_model.py +38 -15
  39. kash/model/paths_model.py +2 -0
  40. kash/shell/output/shell_output.py +10 -8
  41. kash/shell/shell_main.py +2 -2
  42. kash/shell/utils/exception_printing.py +2 -2
  43. kash/text_handling/doc_normalization.py +16 -8
  44. kash/text_handling/markdown_utils.py +83 -2
  45. kash/utils/common/format_utils.py +2 -8
  46. kash/utils/common/inflection.py +22 -0
  47. kash/utils/common/task_stack.py +4 -15
  48. kash/utils/errors.py +14 -9
  49. kash/utils/file_utils/file_formats_model.py +15 -0
  50. kash/utils/file_utils/file_sort_filter.py +10 -3
  51. kash/web_gen/templates/base_styles.css.jinja +8 -3
  52. kash/workspaces/__init__.py +12 -3
  53. kash/workspaces/workspace_dirs.py +58 -0
  54. kash/workspaces/workspace_importing.py +1 -1
  55. kash/workspaces/workspaces.py +26 -90
  56. {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/METADATA +4 -4
  57. {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/RECORD +60 -57
  58. kash/shell/utils/argparse_utils.py +0 -20
  59. kash/utils/lang_utils/inflection.py +0 -18
  60. {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/WHEEL +0 -0
  61. {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/entry_points.txt +0 -0
  62. {kash_shell-0.3.11.dist-info → kash_shell-0.3.12.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ from prettyfmt import abbrev_obj
7
+ from pydantic.dataclasses import dataclass
8
+
9
+ from kash.config.logger import get_logger
10
+ from kash.model.items_model import State
11
+
12
+ if TYPE_CHECKING:
13
+ from kash.file_storage.file_store import FileStore
14
+ from kash.model.actions_model import Action
15
+
16
+
17
+ log = get_logger(__name__)
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class RuntimeSettings:
22
+ """
23
+ Workspace and other runtime settings that may be set across runs of
24
+ one or more actions.
25
+ """
26
+
27
+ workspace_dir: Path
28
+ """The workspace directory in which the action is being executed."""
29
+
30
+ rerun: bool = False
31
+ """If True, always run actions, even cacheable ones that have results."""
32
+
33
+ refetch: bool = False
34
+ """If True, will refetch items even if they are already in the content caches."""
35
+
36
+ override_state: State | None = None
37
+ """If specified, override the state of result items. Useful to mark items as transient."""
38
+
39
+ tmp_output: bool = False
40
+ """If True, will save output items to a temporary file."""
41
+
42
+ no_format: bool = False
43
+ """If True, will not normalize the output item's body text formatting (for Markdown)."""
44
+
45
+ @property
46
+ def workspace(self) -> FileStore:
47
+ from kash.workspaces.workspaces import get_ws
48
+
49
+ return get_ws(self.workspace_dir)
50
+
51
+ @property
52
+ def non_default_options(self) -> dict[str, str]:
53
+ """
54
+ Summarize non-default runtime options as a dict.
55
+ """
56
+ opts: dict[str, str] = {}
57
+ # Only these two settings directly affect the output:
58
+ if self.no_format:
59
+ opts["no_format"] = "true"
60
+ if self.override_state:
61
+ opts["override_state"] = self.override_state.name
62
+ return opts
63
+
64
+ def __repr__(self):
65
+ return abbrev_obj(self, field_max_len=80)
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ExecContext:
70
+ """
71
+ An action and its context for execution. This is a good place for settings
72
+ that apply to any action and are bothersome to pass as parameters.
73
+ """
74
+
75
+ action: Action
76
+ """The action being executed."""
77
+
78
+ settings: RuntimeSettings
79
+ """The workspace and other run-time settings for the action."""
kash/model/items_model.py CHANGED
@@ -25,6 +25,7 @@ from kash.model.media_model import MediaMetadata
25
25
  from kash.model.operations_model import OperationSummary, Source
26
26
  from kash.model.paths_model import StorePath, fmt_store_path
27
27
  from kash.text_handling.markdown_render import markdown_to_html
28
+ from kash.text_handling.markdown_utils import first_heading
28
29
  from kash.utils.common.format_utils import fmt_loc, html_to_plaintext, plaintext_to_html
29
30
  from kash.utils.common.url import Locator, Url
30
31
  from kash.utils.errors import FileFormatError
@@ -32,7 +33,7 @@ from kash.utils.file_formats.chat_format import ChatHistory
32
33
  from kash.utils.file_utils.file_formats_model import FileExt, Format
33
34
 
34
35
  if TYPE_CHECKING:
35
- from kash.model.actions_model import ExecContext
36
+ from kash.model.exec_model import ExecContext
36
37
  from kash.workspaces import Workspace
37
38
 
38
39
  log = get_logger(__name__)
@@ -178,9 +179,7 @@ class ItemId:
178
179
  if item.type == ItemType.resource and item.format == Format.url and item.url:
179
180
  item_id = ItemId(item.type, IdType.url, canonicalize_url(item.url))
180
181
  elif item.type == ItemType.concept and item.title:
181
- item_id = ItemId(
182
- item.type, IdType.concept, canonicalize_concept(item.title)
183
- )
182
+ item_id = ItemId(item.type, IdType.concept, canonicalize_concept(item.title))
184
183
  elif item.source and item.source.cacheable:
185
184
  # We know the source of this and if the action was cacheable, we can create
186
185
  # an identity based on the source.
@@ -281,11 +280,9 @@ class Item:
281
280
  """
282
281
  item_dict = {**item_dict, **kwargs}
283
282
 
284
- info_prefix = (
285
- f"{fmt_store_path(item_dict['store_path'])}: "
286
- if "store_path" in item_dict
287
- else ""
288
- )
283
+ info_prefix = ""
284
+ if "store_path" in item_dict and item_dict["store_path"]:
285
+ info_prefix = f"{fmt_store_path(item_dict['store_path'])}: "
289
286
 
290
287
  # Metadata formats might change over time so it's important to gracefully handle issues.
291
288
  def set_field(key: str, default: Any, cls_: type[T]) -> T:
@@ -314,9 +311,7 @@ class Item:
314
311
  body = item_dict.get("body")
315
312
  history = [OperationSummary(**op) for op in item_dict.get("history", [])]
316
313
  relations = (
317
- ItemRelations(**item_dict["relations"])
318
- if "relations" in item_dict
319
- else ItemRelations()
314
+ ItemRelations(**item_dict["relations"]) if "relations" in item_dict else ItemRelations()
320
315
  )
321
316
  store_path = item_dict.get("store_path")
322
317
 
@@ -334,9 +329,7 @@ class Item:
334
329
  ]
335
330
  all_fields = [f.name for f in cls.__dataclass_fields__.values()]
336
331
  allowed_fields = [f for f in all_fields if f not in excluded_fields]
337
- other_metadata = {
338
- key: value for key, value in item_dict.items() if key in allowed_fields
339
- }
332
+ other_metadata = {key: value for key, value in item_dict.items() if key in allowed_fields}
340
333
  unexpected_metadata = {
341
334
  key: value for key, value in item_dict.items() if key not in all_fields
342
335
  }
@@ -385,9 +378,7 @@ class Item:
385
378
  if not item_type:
386
379
  # Default to doc for general text files and resource for everything else.
387
380
  item_type = (
388
- ItemType.doc
389
- if format and format.supports_frontmatter
390
- else ItemType.resource
381
+ ItemType.doc if format and format.supports_frontmatter else ItemType.resource
391
382
  )
392
383
  item = cls(
393
384
  type=item_type,
@@ -438,11 +429,9 @@ class Item:
438
429
  if not self.format:
439
430
  raise ValueError(f"Item has no format: {self}")
440
431
  if self.type.expects_body and self.format.has_body and not self.body:
441
- raise ValueError(
442
- f"Item type `{self.type.value}` is text but has no body: {self}"
443
- )
432
+ raise ValueError(f"Item type `{self.type.value}` is text but has no body: {self}")
444
433
 
445
- def absolute_path(self, ws: "Workspace | None" = None) -> Path: # noqa: UP037
434
+ def absolute_path(self, ws: Workspace | None = None) -> Path:
446
435
  """
447
436
  Get the absolute path to the item. Throws `ValueError` if the item has no
448
437
  store path. If no workspace is provided, uses the current workspace.
@@ -493,9 +482,7 @@ class Item:
493
482
  return {k: serialize(v) for k, v in v.items()}
494
483
  elif isinstance(v, Enum):
495
484
  return v.value
496
- elif hasattr(
497
- v, "as_dict"
498
- ): # Handle Operation or any object with as_dict method.
485
+ elif hasattr(v, "as_dict"): # Handle Operation or any object with as_dict method.
499
486
  return v.as_dict()
500
487
  elif is_dataclass(v) and not isinstance(v, type):
501
488
  # Handle Python and Pydantic dataclasses.
@@ -532,22 +519,38 @@ class Item:
532
519
  display_title = self.abbrev_title()
533
520
  return display_title
534
521
 
535
- def abbrev_title(self, max_len: int = 100, add_ops_suffix: bool = True) -> str:
522
+ def abbrev_title(
523
+ self,
524
+ *,
525
+ max_len: int = 100,
526
+ add_ops_suffix: bool = False,
527
+ pull_body_heading: bool = False,
528
+ ) -> str:
536
529
  """
537
530
  Get or infer a title for this item, falling back to the filename, URL,
538
531
  description, or finally body text.
539
532
  Optionally, include the last operation as a parenthetical at the end of the title.
540
533
  """
541
- # Special case for URLs with no title..
534
+ from kash.file_storage.store_filenames import parse_item_filename
535
+
536
+ # First special case: if we are pulling the title from the body header, check
537
+ # that.
538
+ if not self.title and pull_body_heading:
539
+ heading = self.body_heading()
540
+ if heading:
541
+ return heading
542
+
543
+ # Next special case: URLs with no title use the url itself.
542
544
  if not self.title and self.url:
543
545
  return abbrev_str(self.url, max_len)
544
546
 
545
547
  # Special case for filenames with no title.
546
- path_name = (
547
- (self.store_path and Path(self.store_path).name)
548
- or (self.external_path and Path(self.external_path).name)
549
- or (self.original_filename and Path(self.original_filename).name)
550
- )
548
+ # Use stem to drop suffix like .resource.docx etc in a title.
549
+ path = self.store_path or self.external_path or self.original_filename
550
+ if path:
551
+ path_name, _item_type, _format, _file_ext = parse_item_filename(Path(path).name)
552
+ else:
553
+ path_name = None
551
554
 
552
555
  # Use the title or the path if possible, falling back to description or even body text.
553
556
  title_raw_text = (
@@ -559,10 +562,14 @@ class Item:
559
562
  )
560
563
 
561
564
  suffix = ""
562
- if add_ops_suffix and self.type not in [ItemType.concept, ItemType.resource]:
563
- # For notes, exports, etc but not for concepts, add a parenthical note
564
- # indicating the last operation, if there was one. This makes filename slugs
565
- # more readable.
565
+ # For docs, etc but not for concepts/resources/exports, add a parenthical note
566
+ # indicating the last operation, if there was one. This makes filename slugs
567
+ # more readable.
568
+ if add_ops_suffix and self.type not in [
569
+ ItemType.concept,
570
+ ItemType.resource,
571
+ ItemType.export,
572
+ ]:
566
573
  last_op = self.history and self.history[-1].action_name
567
574
  if last_op:
568
575
  step_num = len(self.history) + 1 if self.history else 1
@@ -579,9 +586,18 @@ class Item:
579
586
 
580
587
  return final_text
581
588
 
589
+ def body_heading(self) -> str | None:
590
+ """
591
+ Get the first h1 or h2 heading from the body text, if present.
592
+ """
593
+ if self.format in [Format.markdown, Format.md_html]:
594
+ return first_heading(self.body_text(), allowed_tags=("h1", "h2"))
595
+ # TODO: Support HTML <h1> and <h2> as well.
596
+ return None
597
+
582
598
  def abbrev_body(self, max_len: int) -> str:
583
599
  """
584
- Get a cut off version of the body text. Must not be a binary Item.
600
+ Get an abbreviated version of the body text. Must not be a binary Item.
585
601
  Abbreviates YAML bodies like {"role": "user", "content": "Hello"} to "user Hello".
586
602
  """
587
603
  body_text = self.body_text()[:max_len]
@@ -609,7 +625,7 @@ class Item:
609
625
  Get a readable slugified version of the title or filename or content
610
626
  appropriate for this item. May not be unique.
611
627
  """
612
- title = self.abbrev_title(max_len=max_len)
628
+ title = self.abbrev_title(max_len=max_len, add_ops_suffix=True)
613
629
  slug = slugify_snake(title)
614
630
  return slug
615
631
 
@@ -617,9 +633,7 @@ class Item:
617
633
  """
618
634
  Get or infer description.
619
635
  """
620
- return abbrev_on_words(
621
- html_to_plaintext(self.description or self.body or ""), max_len
622
- )
636
+ return abbrev_on_words(html_to_plaintext(self.description or self.body or ""), max_len)
623
637
 
624
638
  def read_as_config(self) -> Any:
625
639
  """
@@ -656,6 +670,9 @@ class Item:
656
670
  elif self.type == ItemType.script:
657
671
  # Same for kash/xonsh scripts.
658
672
  return f"{self.type.value}.{FileExt.xsh.value}"
673
+ elif self.type == ItemType.export:
674
+ # For exports, skip the item type to keep it maximally compatible for external tools.
675
+ return f"{self.get_file_ext().value}"
659
676
  else:
660
677
  return f"{self.type.value}.{self.get_file_ext().value}"
661
678
 
@@ -668,11 +685,19 @@ class Item:
668
685
  return "\n\n".join(part for part in parts if part)
669
686
 
670
687
  def body_text(self) -> str:
688
+ """
689
+ Body text of the item, also validating that the item is not binary.
690
+ """
671
691
  if self.is_binary:
672
692
  raise ValueError("Cannot get text content of a binary Item")
673
693
  return self.body or ""
674
694
 
675
695
  def body_as_html(self) -> str:
696
+ """
697
+ Body of the item, converted to HTML format. Validates that the body format can be
698
+ converted and then converts plaintext or Markdown to HTML. Simply returns the body
699
+ if it is already HTML.
700
+ """
676
701
  if self.format == Format.html:
677
702
  return self.body_text()
678
703
  elif self.format == Format.plaintext:
@@ -708,12 +733,10 @@ class Item:
708
733
  self, update_timestamp: bool = True, **other_updates: Unpack[ItemUpdateOptions]
709
734
  ) -> Item:
710
735
  """
711
- Copy item with the given field updates. Resets store_path to None. Updates
712
- created time if requested.
736
+ Copy item with the given field updates. Resets `store_path` to None but preserves
737
+ other fields, including the body. Updates created time if requested.
713
738
  """
714
- new_fields = self._copy_and_update(
715
- update_timestamp=update_timestamp, **other_updates
716
- )
739
+ new_fields = self._copy_and_update(update_timestamp=update_timestamp, **other_updates)
717
740
  return Item(**new_fields)
718
741
 
719
742
  def merged_copy(self, other: Item) -> Item:
@@ -734,7 +757,7 @@ class Item:
734
757
  if self.relations.derived_from:
735
758
  log.message(
736
759
  "Deriving from an item that has not been saved so using "
737
- "its derived_from relation: %s on %s",
760
+ "upstream derived_from relation: %s on %s",
738
761
  self.relations.derived_from,
739
762
  self,
740
763
  )
@@ -768,9 +791,7 @@ class Item:
768
791
 
769
792
  # Fall back to action title template if we have it and title wasn't explicitly set.
770
793
  if "title" not in updates:
771
- prev_title = self.title or (
772
- Path(self.store_path).stem if self.store_path else UNTITLED
773
- )
794
+ prev_title = self.title or (Path(self.store_path).stem if self.store_path else UNTITLED)
774
795
  if self.context:
775
796
  action = self.context.action
776
797
  new_item.title = action.title_template.format(
@@ -25,27 +25,46 @@ class OperationSummary:
25
25
  class Input:
26
26
  """
27
27
  An input to an operation, which may include a hash fingerprint.
28
+ Typically an input is a StorePath, but it could be something else like an in-memory
29
+ item that hasn't been saved yet.
28
30
  """
29
31
 
30
- # TODO: May want to support Locators or other inputs besides StorePaths.
31
- path: StorePath
32
+ path: StorePath | None
32
33
  hash: str | None = None
34
+ source_info: str | None = None
33
35
 
34
36
  @classmethod
35
37
  def parse(cls, input_str: str) -> Input:
36
38
  """
37
- Parse an Input string in the format `some/path/filename.ext@sha1:hash` or
38
- `@some/path/filename.ext@sha1:hash`, with a store path and a hash.
39
+ Parse an Input string in the format printed by `Input.parseable_str()`.
39
40
  """
40
- parts = input_str.rsplit("@", 1)
41
- if len(parts) == 2:
42
- path, hash = parts
43
- return cls(path=StorePath(path), hash=hash)
41
+ if input_str.startswith("[") and input_str.endswith("]"):
42
+ return cls(path=None, hash=None, source_info=input_str[1:-1])
44
43
  else:
45
- return cls(path=StorePath(input_str), hash=None)
46
-
47
- def path_and_hash(self):
48
- return f"{fmt_loc(self.path)}@{self.hash}"
44
+ parts = input_str.rsplit("@", 1)
45
+ if len(parts) == 2:
46
+ path, hash = parts
47
+ return cls(path=StorePath(path), hash=hash)
48
+ else:
49
+ return cls(path=StorePath(input_str), hash=None)
50
+
51
+ def parseable_str(self):
52
+ """
53
+ A readable and parseable string describing the input, typically a hash and a path but
54
+ could be a path without a hash or another info in brackets. Paths may have an `@` at the
55
+ front.
56
+
57
+ some/path.txt@sha1:1234567890
58
+ @some/path.txt@sha1:1234567890
59
+ some/path.txt
60
+ [unsaved]
61
+ """
62
+ if self.path and self.hash:
63
+ return f"{fmt_loc(self.path)}@{self.hash}"
64
+ elif self.source_info:
65
+ return f"[{self.source_info}]"
66
+ else:
67
+ return "[input info missing]"
49
68
 
50
69
  # Inputs are equal if the hashes match (even if the paths have changed).
51
70
 
@@ -53,6 +72,10 @@ class Input:
53
72
  return hash(self.hash) if self.hash else object.__hash__(self)
54
73
 
55
74
  def __eq__(self, other: Any) -> bool:
75
+ """
76
+ Inputs are equal if the hashes match (even if the paths have changed) or if the paths
77
+ are the same. They are *not* equal otherwise, even if the source_info is the same.
78
+ """
56
79
  if not isinstance(other, Input):
57
80
  return NotImplemented
58
81
  if self.hash and other.hash:
@@ -62,7 +85,7 @@ class Input:
62
85
  return False
63
86
 
64
87
  def __str__(self):
65
- return self.path_and_hash()
88
+ return self.parseable_str()
66
89
 
67
90
 
68
91
  @dataclass(frozen=True)
@@ -88,7 +111,7 @@ class Operation:
88
111
  }
89
112
 
90
113
  if self.arguments:
91
- d["arguments"] = [arg.path_and_hash() for arg in self.arguments]
114
+ d["arguments"] = [arg.parseable_str() for arg in self.arguments]
92
115
  if self.options:
93
116
  d["options"] = self.options
94
117
 
@@ -101,7 +124,7 @@ class Operation:
101
124
  return [shell_quote(str(arg.path)) for arg in self.arguments]
102
125
 
103
126
  def hashed_args(self):
104
- return [arg.path_and_hash() for arg in self.arguments]
127
+ return [arg.parseable_str() for arg in self.arguments]
105
128
 
106
129
  def quoted_options(self):
107
130
  return [f"--{k}={shell_quote(str(v))}" for k, v in self.options.items()]
kash/model/paths_model.py CHANGED
@@ -264,6 +264,8 @@ def fmt_store_path(store_path: str | Path | StorePath) -> str:
264
264
  """
265
265
  Format a store path as a string.
266
266
  """
267
+ if not store_path:
268
+ raise ValueError("Cannot format empty store path")
267
269
  return StorePath(store_path).display_str()
268
270
 
269
271
 
@@ -16,7 +16,7 @@ from rich.rule import Rule
16
16
  from rich.style import Style
17
17
  from rich.text import Text
18
18
 
19
- from kash.config.logger import get_console
19
+ from kash.config.logger import get_console, is_console_quiet
20
20
  from kash.config.text_styles import (
21
21
  COLOR_HINT_DIM,
22
22
  COLOR_RESPONSE,
@@ -31,9 +31,6 @@ from kash.shell.output.kmarkdown import KMarkdown
31
31
  from kash.utils.rich_custom.rich_indent import Indent
32
32
  from kash.utils.rich_custom.rich_markdown_fork import Markdown
33
33
 
34
- console = get_console()
35
-
36
-
37
34
  print_context_var: contextvars.ContextVar[str] = contextvars.ContextVar("print_prefix", default="")
38
35
  """
39
36
  Context variable override for print prefix.
@@ -99,7 +96,12 @@ def rich_print(
99
96
  Print to the Rich console, either the global console or a thread-local
100
97
  override, if one is active. With `raw` true, we bypass rich formatting
101
98
  entirely and simply write to the console stream.
99
+
100
+ Output is suppressed by the global `console_quiet` setting.
102
101
  """
102
+ if is_console_quiet():
103
+ return
104
+
103
105
  console = get_console()
104
106
  if raw:
105
107
  # TODO: Indent not supported in raw mode.
@@ -136,7 +138,7 @@ def cprint(
136
138
  raw: bool = False,
137
139
  ):
138
140
  """
139
- Main way to print to the shell. Wraps `rprint` with our additional
141
+ Main way to print to the shell. Wraps `rich_print` with our additional
140
142
  formatting options for text fill and prefix.
141
143
  """
142
144
  empty_indent = extra_indent.strip()
@@ -323,8 +325,8 @@ class PrintHooks(Enum):
323
325
  after_command_run = "after_command_run"
324
326
  before_status = "before_status"
325
327
  after_status = "after_status"
326
- before_shell_action_run = "before_action_run"
327
- after_shell_action_run = "after_action_run"
328
+ before_shell_action_run = "before_shell_action_run"
329
+ after_shell_action_run = "after_shell_action_run"
328
330
  before_log_action_run = "before_log_action_run"
329
331
  before_assistance = "before_assistance"
330
332
  after_assistance = "after_assistance"
@@ -376,7 +378,7 @@ class PrintHooks(Enum):
376
378
  elif self == PrintHooks.nonfatal_exception:
377
379
  self.nl()
378
380
  elif self == PrintHooks.before_done_message:
379
- self.nl()
381
+ pass
380
382
  elif self == PrintHooks.before_output:
381
383
  self.nl()
382
384
  elif self == PrintHooks.after_output:
kash/shell/shell_main.py CHANGED
@@ -14,10 +14,10 @@ import argparse
14
14
  import threading
15
15
 
16
16
  import xonsh.main
17
+ from clideps.utils.readable_argparse import ReadableColorFormatter
17
18
  from strif import quote_if_needed
18
19
 
19
20
  from kash.config.setup import kash_setup
20
- from kash.shell.utils.argparse_utils import WrappedColorFormatter
21
21
  from kash.shell.version import get_full_version_name, get_version
22
22
  from kash.xonsh_custom.custom_shell import install_to_xonshrc, start_shell
23
23
 
@@ -51,7 +51,7 @@ def run_shell(single_command: str | None = None):
51
51
 
52
52
 
53
53
  def build_parser() -> argparse.ArgumentParser:
54
- parser = argparse.ArgumentParser(description=__doc__, formatter_class=WrappedColorFormatter)
54
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=ReadableColorFormatter)
55
55
 
56
56
  parser.add_argument("--version", action="version", version=get_full_version_name())
57
57
 
@@ -5,7 +5,7 @@ from typing import TypeVar
5
5
  from kash.config.logger import get_logger
6
6
  from kash.config.text_styles import COLOR_ERROR
7
7
  from kash.shell.output.shell_output import PrintHooks
8
- from kash.utils.errors import NONFATAL_EXCEPTIONS
8
+ from kash.utils.errors import get_nonfatal_exceptions
9
9
 
10
10
  log = get_logger(__name__)
11
11
 
@@ -41,7 +41,7 @@ def wrap_with_exception_printing(func: Callable[..., R]) -> Callable[[list[str]]
41
41
  (", ".join(str(arg) for arg in args)),
42
42
  )
43
43
  return func(*args)
44
- except NONFATAL_EXCEPTIONS as e:
44
+ except get_nonfatal_exceptions() as e:
45
45
  PrintHooks.nonfatal_exception()
46
46
  log.error(f"[{COLOR_ERROR}]Command error:[/{COLOR_ERROR}] %s", summarize_traceback(e))
47
47
  log.info("Command error details: %s", e, exc_info=True)
@@ -2,7 +2,7 @@ from pathlib import Path
2
2
 
3
3
  from flowmark import fill_markdown, fill_text, line_wrap_by_sentence
4
4
  from flowmark.text_filling import DEFAULT_WRAP_WIDTH
5
- from flowmark.text_wrapping import simple_word_splitter, wrap_paragraph
5
+ from flowmark.text_wrapping import simple_word_splitter
6
6
  from frontmatter_format import fmf_read, fmf_write
7
7
 
8
8
  from kash.utils.common.format_utils import fmt_loc
@@ -11,20 +11,26 @@ from kash.utils.file_utils.file_formats_model import Format, detect_file_format
11
11
  from kash.utils.rich_custom.ansi_cell_len import ansi_cell_len
12
12
 
13
13
 
14
- def normalize_formatting_ansi(text: str, format: Format | None, width=DEFAULT_WRAP_WIDTH) -> str:
14
+ def normalize_formatting(
15
+ text: str,
16
+ format: Format | None,
17
+ width=DEFAULT_WRAP_WIDTH,
18
+ support_ansi: bool = True,
19
+ cleanups: bool = True,
20
+ ) -> str:
15
21
  """
16
22
  Normalize text formatting by wrapping lines and normalizing Markdown.
23
+ This only does "safe" normalizations that cannot break the text.
17
24
  Enables ANSI support so ANSI codes and OSC-8 links are correctly handled.
18
25
  """
26
+ len_fn = ansi_cell_len if support_ansi else len
19
27
  if format == Format.plaintext:
20
- return fill_text(
21
- text, width=width, word_splitter=simple_word_splitter, len_fn=ansi_cell_len
22
- )
28
+ return fill_text(text, width=width, word_splitter=simple_word_splitter, len_fn=len_fn)
23
29
  elif format == Format.markdown or format == Format.md_html:
24
30
  return fill_markdown(
25
31
  text,
26
- line_wrapper=line_wrap_by_sentence(len_fn=ansi_cell_len, is_markdown=True),
27
- cleanups=True, # Safe cleanups like unbolding section headers.
32
+ line_wrapper=line_wrap_by_sentence(len_fn=len_fn, is_markdown=True),
33
+ cleanups=cleanups,
28
34
  )
29
35
  elif format == Format.html:
30
36
  # We don't currently auto-format HTML as we sometimes use HTML with specifically chosen line breaks.
@@ -37,6 +43,7 @@ def normalize_text_file(
37
43
  path: str | Path,
38
44
  target_path: Path,
39
45
  format: Format | None = None,
46
+ support_ansi: bool = True,
40
47
  ) -> None:
41
48
  """
42
49
  Normalize formatting on a text file, handling Markdown, HTML, or text, as well as
@@ -48,7 +55,7 @@ def normalize_text_file(
48
55
  raise ValueError(f"Cannot format non-text files: {fmt_loc(path)}")
49
56
 
50
57
  content, metadata = fmf_read(path)
51
- norm_content = normalize_formatting_ansi(content, format=format)
58
+ norm_content = normalize_formatting(content, format=format, support_ansi=support_ansi)
52
59
  fmf_write(not_none(target_path), norm_content, metadata)
53
60
 
54
61
 
@@ -57,6 +64,7 @@ def normalize_text_file(
57
64
 
58
65
  def test_osc8_link():
59
66
  from clideps.terminal.osc_utils import osc8_link
67
+ from flowmark.text_wrapping import wrap_paragraph
60
68
 
61
69
  link = osc8_link("https://example.com/" + "x" * 50, "Example")
62
70
  assert ansi_cell_len(link) == 7