intui 1.0.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 (70) hide show
  1. intui/__init__.py +79 -0
  2. intui/actions/__init__.py +15 -0
  3. intui/actions/confirm.py +63 -0
  4. intui/actions/files.py +58 -0
  5. intui/actions/intents.py +44 -0
  6. intui/adapters/__init__.py +10 -0
  7. intui/adapters/intentforge.py +259 -0
  8. intui/app.py +189 -0
  9. intui/console/__init__.py +12 -0
  10. intui/console/app.py +297 -0
  11. intui/console/cli.py +137 -0
  12. intui/console/runner.py +50 -0
  13. intui/emit.py +380 -0
  14. intui/events/__init__.py +36 -0
  15. intui/events/envelope.py +144 -0
  16. intui/events/process.py +117 -0
  17. intui/events/recording.py +62 -0
  18. intui/events/sources.py +230 -0
  19. intui/events/stream.py +103 -0
  20. intui/events/validate.py +108 -0
  21. intui/kit/__init__.py +71 -0
  22. intui/kit/activity_strip.py +63 -0
  23. intui/kit/chip.py +100 -0
  24. intui/kit/command_bar.py +101 -0
  25. intui/kit/command_palette.py +126 -0
  26. intui/kit/conversation_log.py +56 -0
  27. intui/kit/diff_viewer.py +139 -0
  28. intui/kit/evidence_panel.py +72 -0
  29. intui/kit/file_tree.py +147 -0
  30. intui/kit/lanes.py +116 -0
  31. intui/kit/metrics_panel.py +66 -0
  32. intui/kit/mode_strip.py +70 -0
  33. intui/kit/prompt_input.py +45 -0
  34. intui/kit/state/__init__.py +226 -0
  35. intui/kit/state/activity.py +45 -0
  36. intui/kit/state/artifacts.py +390 -0
  37. intui/kit/state/commands.py +140 -0
  38. intui/kit/state/conversation.py +134 -0
  39. intui/kit/state/metrics.py +182 -0
  40. intui/kit/state/model.py +115 -0
  41. intui/kit/state/modes.py +68 -0
  42. intui/kit/state/reduce.py +160 -0
  43. intui/kit/state/run_status.py +55 -0
  44. intui/kit/state/selectors.py +250 -0
  45. intui/kit/state/views.py +81 -0
  46. intui/kit/state/workspace.py +216 -0
  47. intui/kit/tree.py +87 -0
  48. intui/kit/view_router.py +62 -0
  49. intui/py.typed +0 -0
  50. intui/state/__init__.py +18 -0
  51. intui/state/reducer.py +47 -0
  52. intui/state/snapshot.py +59 -0
  53. intui/state/store.py +130 -0
  54. intui/state/timeline.py +53 -0
  55. intui/theming/__init__.py +19 -0
  56. intui/theming/default.py +25 -0
  57. intui/theming/signal_style.py +59 -0
  58. intui/theming/theme.py +46 -0
  59. intui/viewmodels/__init__.py +6 -0
  60. intui/viewmodels/health.py +41 -0
  61. intui/viewmodels/selector.py +40 -0
  62. intui/widgets/__init__.py +15 -0
  63. intui/widgets/bound.py +99 -0
  64. intui/widgets/bridge.py +87 -0
  65. intui/widgets/signal.py +141 -0
  66. intui-1.0.0.dist-info/METADATA +173 -0
  67. intui-1.0.0.dist-info/RECORD +70 -0
  68. intui-1.0.0.dist-info/WHEEL +4 -0
  69. intui-1.0.0.dist-info/entry_points.txt +2 -0
  70. intui-1.0.0.dist-info/licenses/LICENSE +21 -0
intui/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """in-TUI-tion: a library for rich, first-class terminal user interfaces.
2
+
3
+ The engine-free pipeline core is re-exported here (``import intui`` pulls in
4
+ no terminal engine). The rendering layer lives in :mod:`intui.widgets` and
5
+ :mod:`intui.app` — import those explicitly when building a UI.
6
+
7
+ Public API contract: specs/001-core-library-foundation/contracts/public-api.md
8
+ """
9
+
10
+ from intui.actions import ConfirmationFlow, Intent, IntentHandler
11
+ from intui.emit import RunRecorder, run_recorder
12
+ from intui.events import (
13
+ EnvelopeError,
14
+ Event,
15
+ EventSource,
16
+ EventStream,
17
+ JsonlReplaySource,
18
+ MemorySource,
19
+ Scope,
20
+ StreamError,
21
+ StreamHealth,
22
+ StreamIssue,
23
+ StreamState,
24
+ parse_event,
25
+ read_recording,
26
+ validate_event,
27
+ validate_stream,
28
+ write_recording,
29
+ )
30
+ from intui.state import Reducer, ReducerError, SliceReducer, Snapshot, Store, compose_reducers
31
+ from intui.theming import (
32
+ DEFAULT_THEME,
33
+ MotionMode,
34
+ StatusStyle,
35
+ Theme,
36
+ resolve_status_style,
37
+ )
38
+ from intui.viewmodels import HealthView, Selector, health_view, selector
39
+
40
+ __version__ = "1.0.0"
41
+
42
+ __all__ = [
43
+ "ConfirmationFlow",
44
+ "DEFAULT_THEME",
45
+ "EnvelopeError",
46
+ "Event",
47
+ "EventSource",
48
+ "EventStream",
49
+ "HealthView",
50
+ "Intent",
51
+ "IntentHandler",
52
+ "JsonlReplaySource",
53
+ "MemorySource",
54
+ "MotionMode",
55
+ "Reducer",
56
+ "ReducerError",
57
+ "RunRecorder",
58
+ "Scope",
59
+ "Selector",
60
+ "SliceReducer",
61
+ "Snapshot",
62
+ "StatusStyle",
63
+ "Store",
64
+ "StreamError",
65
+ "StreamHealth",
66
+ "StreamIssue",
67
+ "StreamState",
68
+ "Theme",
69
+ "compose_reducers",
70
+ "health_view",
71
+ "parse_event",
72
+ "read_recording",
73
+ "resolve_status_style",
74
+ "run_recorder",
75
+ "selector",
76
+ "validate_event",
77
+ "validate_stream",
78
+ "write_recording",
79
+ ]
@@ -0,0 +1,15 @@
1
+ """intui.actions: intents, the risky-action confirmation flow, and opt-in
2
+ file-action helpers an app calls to fulfill file intents."""
3
+
4
+ from intui.actions.confirm import ConfirmationFlow
5
+ from intui.actions.files import delete_path, open_in_editor, save_copy
6
+ from intui.actions.intents import Intent, IntentHandler
7
+
8
+ __all__ = [
9
+ "ConfirmationFlow",
10
+ "Intent",
11
+ "IntentHandler",
12
+ "delete_path",
13
+ "open_in_editor",
14
+ "save_copy",
15
+ ]
@@ -0,0 +1,63 @@
1
+ """Confirmation flow for risky intents (FR-016).
2
+
3
+ Lifecycle: ``created -> (confirming -> confirmed | cancelled) -> delivered``.
4
+ Non-risky intents skip the confirming states. While a confirmation is
5
+ pending, the prompt owns the interaction: further submissions are rejected
6
+ until the pending intent is resolved.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+
13
+ from intui.actions.intents import Intent
14
+
15
+ Deliver = Callable[[Intent], None]
16
+
17
+
18
+ class ConfirmationFlow:
19
+ """Pure state machine; UI prompts subscribe via ``on_pending_changed``."""
20
+
21
+ def __init__(
22
+ self,
23
+ deliver: Deliver,
24
+ on_pending_changed: Callable[[Intent | None], None] | None = None,
25
+ ) -> None:
26
+ self._deliver = deliver
27
+ self._on_pending_changed = on_pending_changed
28
+ self._pending: Intent | None = None
29
+
30
+ @property
31
+ def pending(self) -> Intent | None:
32
+ return self._pending
33
+
34
+ def submit(self, intent: Intent) -> bool:
35
+ """Submit an intent. Returns False if rejected (confirmation pending)."""
36
+ if self._pending is not None:
37
+ return False
38
+ if intent.risky:
39
+ self._set_pending(intent)
40
+ return True
41
+ self._deliver(intent)
42
+ return True
43
+
44
+ def confirm(self) -> None:
45
+ """Deliver the pending intent. No-op when nothing is pending."""
46
+ pending, self._pending = self._pending, None
47
+ if pending is not None:
48
+ self._notify()
49
+ self._deliver(pending)
50
+
51
+ def cancel(self) -> None:
52
+ """Discard the pending intent — it is never delivered."""
53
+ if self._pending is not None:
54
+ self._pending = None
55
+ self._notify()
56
+
57
+ def _set_pending(self, intent: Intent) -> None:
58
+ self._pending = intent
59
+ self._notify()
60
+
61
+ def _notify(self) -> None:
62
+ if self._on_pending_changed is not None:
63
+ self._on_pending_changed(self._pending)
intui/actions/files.py ADDED
@@ -0,0 +1,58 @@
1
+ """Opt-in file-action helpers an application calls to fulfill file intents.
2
+
3
+ These are conveniences — the library NEVER calls them on its own (Principle III).
4
+ An app wires them from its ``handle_intent`` (or enables them in the console via
5
+ ``build_console(file_actions=True)``). Engine-free (stdlib only); ``open_in_editor``
6
+ takes an injectable ``run`` so callers/tests can avoid launching a process.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from collections.abc import Callable
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ Runner = Callable[[list[str]], Any]
20
+
21
+
22
+ def delete_path(path: Path | str) -> None:
23
+ """Delete a file. Raises ``FileNotFoundError`` if it is absent."""
24
+ Path(path).unlink()
25
+
26
+
27
+ def save_copy(src: Path | str, dst: Path | str) -> Path:
28
+ """Copy ``src`` to ``dst`` and return the destination path."""
29
+ shutil.copy(src, dst)
30
+ return Path(dst)
31
+
32
+
33
+ def open_in_editor(
34
+ path: Path | str,
35
+ *,
36
+ editor: str | None = None,
37
+ run: Runner | None = None,
38
+ ) -> list[str]:
39
+ """Open ``path`` in an editor and return the argv that was invoked.
40
+
41
+ Resolution order: ``editor`` → ``$EDITOR`` → ``$VISUAL`` → a platform opener
42
+ (``cmd /c start`` on Windows, ``open`` on macOS, ``xdg-open`` elsewhere).
43
+ ``run`` defaults to :class:`subprocess.Popen`; inject it to capture the argv
44
+ without launching anything.
45
+ """
46
+ runner: Runner = run if run is not None else subprocess.Popen
47
+ target = str(path)
48
+ chosen = editor or os.environ.get("EDITOR") or os.environ.get("VISUAL")
49
+ if chosen:
50
+ argv = [chosen, target]
51
+ elif sys.platform == "win32":
52
+ argv = ["cmd", "/c", "start", "", target]
53
+ elif sys.platform == "darwin":
54
+ argv = ["open", target]
55
+ else:
56
+ argv = ["xdg-open", target]
57
+ runner(argv)
58
+ return argv
@@ -0,0 +1,44 @@
1
+ """Intents: named, validated user-triggered requests (Principle III).
2
+
3
+ The application's :class:`IntentHandler` is the sole mutation seam — the
4
+ library delivers intents and never mutates application state itself
5
+ (FR-014). Handlers typically respond by appending new events, which flow
6
+ back through the pipeline.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass, field
13
+ from types import MappingProxyType
14
+ from typing import Any, Protocol
15
+
16
+ _EMPTY_PAYLOAD: Mapping[str, Any] = MappingProxyType({})
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Intent:
21
+ """A named user-triggered request.
22
+
23
+ ``risky=True`` intents are held by the confirmation flow until the user
24
+ explicitly confirms (FR-016).
25
+ """
26
+
27
+ name: str
28
+ payload: Mapping[str, Any] = field(default_factory=lambda: _EMPTY_PAYLOAD)
29
+ risky: bool = False
30
+
31
+ def __eq__(self, other: object) -> bool:
32
+ if not isinstance(other, Intent):
33
+ return NotImplemented
34
+ return (
35
+ self.name == other.name
36
+ and dict(self.payload) == dict(other.payload)
37
+ and self.risky == other.risky
38
+ )
39
+
40
+
41
+ class IntentHandler(Protocol):
42
+ """The application seam: receives confirmed intents asynchronously."""
43
+
44
+ async def __call__(self, intent: Intent) -> None: ...
@@ -0,0 +1,10 @@
1
+ """intui.adapters: engine-free normalizers from third-party producers.
2
+
3
+ An adapter maps a producer's on-the-wire events onto the canonical in-TUI-tion
4
+ vocabulary so the zero-config runner renders them with no producer-specific code
5
+ in the console or the kit. Adapters import no terminal engine (layering guard).
6
+ """
7
+
8
+ from intui.adapters.intentforge import IntentForgeSource, adapt_record
9
+
10
+ __all__ = ["IntentForgeSource", "adapt_record"]
@@ -0,0 +1,259 @@
1
+ """IntentForge adapter: normalize IF run-trace ndjson into canonical events.
2
+
3
+ Engine-free. IntentForge emits ``--event-stream ndjson`` lines of
4
+ ``{"type":"run_trace_event","event":{"sequence","name","payload"}}`` and a final
5
+ ``{"type":"summary","summary":{...}}``. The inner record is NOT an intui envelope,
6
+ so this module *transforms* each record into one (vs. the plain unwrap that
7
+ :class:`~intui.events.NdjsonStreamSource` does). IntentForge is not imported —
8
+ the coupling is to its on-the-wire JSON shape only.
9
+
10
+ Mapping verified against the IF repo 2026-06-14 (run_trace.py, assembly_executor
11
+ .py, assembly_benchmark.py, file_diff.py, cli.py). See
12
+ specs/010-intentforge-adapter/data-model.md.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import AsyncIterable, AsyncIterator, Iterable, Mapping, Sequence
18
+ from datetime import UTC, datetime, timedelta
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from intui.events.envelope import Event, Scope
23
+ from intui.events.sources import _aiter_subprocess_lines, _aiter_text_lines
24
+
25
+ #: Deterministic time base — IF events carry no timestamp, so we synthesize a
26
+ #: monotonic one from the record ``sequence`` to keep replays deterministic.
27
+ _EPOCH = datetime(2020, 1, 1, tzinfo=UTC)
28
+
29
+ #: Summary top-level keys lifted into evidence metrics (present-only).
30
+ _SUMMARY_METRIC_KEYS = (
31
+ "case_pass_rate",
32
+ "quality_issue_count",
33
+ "delivered_files",
34
+ "evidence_signature",
35
+ )
36
+
37
+
38
+ def adapt_record(record: Mapping[str, Any], *, run_id: str = "intentforge") -> Event | None:
39
+ """Map one IntentForge record to a canonical :class:`Event`, or ``None``.
40
+
41
+ Accepts a ``run_trace_event`` wrapper, a bare ``{sequence,name,payload}``, or
42
+ a ``{"type":"summary","summary":{...}}`` record. Never raises on missing keys.
43
+ """
44
+ if record.get("type") == "summary":
45
+ return _adapt_summary(record.get("summary"), run_id=run_id)
46
+
47
+ inner = record.get("event") if record.get("type") == "run_trace_event" else record
48
+ if not isinstance(inner, Mapping):
49
+ return None
50
+ name = inner.get("name")
51
+ if not isinstance(name, str):
52
+ return None
53
+ sequence = inner.get("sequence")
54
+ sequence = sequence if isinstance(sequence, int) else 0
55
+ payload = inner.get("payload")
56
+ payload = payload if isinstance(payload, Mapping) else {}
57
+
58
+ builder = _MAPPING.get(name)
59
+ if builder is None:
60
+ return None
61
+ type_, scope, out_payload, status = builder(payload)
62
+ return Event(
63
+ version="1",
64
+ event_id=f"if-{sequence}",
65
+ run_id=run_id,
66
+ timestamp=_EPOCH + timedelta(seconds=sequence),
67
+ type=type_,
68
+ scope=scope,
69
+ status=status,
70
+ payload=out_payload,
71
+ )
72
+
73
+
74
+ # --- Per-event builders ------------------------------------------------------
75
+ # Each returns (canonical_type, scope, payload, status).
76
+
77
+ _Built = tuple[str, Scope, dict[str, Any], str | None]
78
+
79
+
80
+ def _item_id(payload: Mapping[str, Any]) -> str:
81
+ # Assembly items carry their id in case_id; file_diff sets work_item_id too.
82
+ wid = payload.get("work_item_id")
83
+ if isinstance(wid, str) and wid:
84
+ return wid
85
+ cid = payload.get("case_id")
86
+ return cid if isinstance(cid, str) else ""
87
+
88
+
89
+ def _case_id(payload: Mapping[str, Any]) -> str:
90
+ cid = payload.get("case_id")
91
+ return cid if isinstance(cid, str) else ""
92
+
93
+
94
+ def _suite_id(payload: Mapping[str, Any]) -> str | None:
95
+ # IF 0.9.13+ carries the parent blueprint/suite as suite_id; absent in older
96
+ # streams (work items then fall back to the kit's "unassigned" group).
97
+ sid = payload.get("suite_id")
98
+ return sid if isinstance(sid, str) and sid else None
99
+
100
+
101
+ def _status(payload: Mapping[str, Any]) -> str | None:
102
+ status = payload.get("status")
103
+ return status if isinstance(status, str) else None
104
+
105
+
106
+ def _case_started(p: Mapping[str, Any]) -> _Built:
107
+ return "task_started", Scope(task_id=_case_id(p)), {}, None
108
+
109
+
110
+ def _case_finished(p: Mapping[str, Any]) -> _Built:
111
+ return "task_completed", Scope(task_id=_case_id(p)), {}, _status(p)
112
+
113
+
114
+ def _item_started(p: Mapping[str, Any]) -> _Built:
115
+ return "work_item_started", Scope(task_id=_suite_id(p), work_item_id=_item_id(p)), {}, None
116
+
117
+
118
+ def _item_committed(p: Mapping[str, Any]) -> _Built:
119
+ return "work_item_completed", Scope(task_id=_suite_id(p), work_item_id=_item_id(p)), {}, None
120
+
121
+
122
+ def _item_failed(p: Mapping[str, Any]) -> _Built:
123
+ return (
124
+ "work_item_completed",
125
+ Scope(task_id=_suite_id(p), work_item_id=_item_id(p)),
126
+ {},
127
+ _status(p) or "failed",
128
+ )
129
+
130
+
131
+ def _plan_blocked(p: Mapping[str, Any]) -> _Built:
132
+ return "task_blocked", Scope(task_id=_case_id(p)), {}, _status(p)
133
+
134
+
135
+ def _suite_started(p: Mapping[str, Any]) -> _Built:
136
+ return "run_started", Scope(), {}, None
137
+
138
+
139
+ def _suite_finished(p: Mapping[str, Any]) -> _Built:
140
+ return "run_completed", Scope(), {}, _status(p)
141
+
142
+
143
+ def _file_diff(p: Mapping[str, Any]) -> _Built:
144
+ diff = p.get("diff")
145
+ title = p.get("file")
146
+ return (
147
+ "diff_ready",
148
+ Scope(task_id=_suite_id(p), work_item_id=_item_id(p)),
149
+ {
150
+ "unified": diff if isinstance(diff, str) else "",
151
+ "title": title if isinstance(title, str) and title else "diff",
152
+ "public_safe": True,
153
+ },
154
+ None,
155
+ )
156
+
157
+
158
+ def _repeat_started(p: Mapping[str, Any]) -> _Built:
159
+ text = f"repeat {p.get('repeat_count', '?')} started (run {p.get('run_index', '?')})"
160
+ return "message_added", Scope(), {"role": "system", "text": text}, None
161
+
162
+
163
+ def _repeat_finished(p: Mapping[str, Any]) -> _Built:
164
+ text = f"repeat {p.get('repeat_count', '?')} finished: {p.get('status', '?')}"
165
+ return "message_added", Scope(), {"role": "system", "text": text}, None
166
+
167
+
168
+ _MAPPING: Mapping[str, Any] = {
169
+ "case_started": _case_started,
170
+ "case_finished": _case_finished,
171
+ "assembly_item_started": _item_started,
172
+ "assembly_item_committed": _item_committed,
173
+ "assembly_item_failed": _item_failed,
174
+ "assembly_plan_blocked": _plan_blocked,
175
+ "matrix_suite_started": _suite_started,
176
+ "matrix_suite_finished": _suite_finished,
177
+ "file_diff": _file_diff,
178
+ "repeat_started": _repeat_started,
179
+ "repeat_finished": _repeat_finished,
180
+ }
181
+
182
+
183
+ def _adapt_summary(summary: Any, *, run_id: str) -> Event | None:
184
+ if not isinstance(summary, Mapping):
185
+ return None
186
+ metrics: list[dict[str, Any]] = []
187
+ for key in _SUMMARY_METRIC_KEYS:
188
+ if key in summary:
189
+ metrics.append({"key": key, "label": key.replace("_", " "), "value": summary[key]})
190
+ acb = summary.get("acb_score")
191
+ if isinstance(acb, Mapping) and "certified_level" in acb:
192
+ metrics.append(
193
+ {"key": "certified_level", "label": "certified level", "value": acb["certified_level"]}
194
+ )
195
+ return Event(
196
+ version="1",
197
+ event_id="if-summary",
198
+ run_id=run_id,
199
+ timestamp=_EPOCH + timedelta(days=1), # after any sequenced event
200
+ type="evidence_ready",
201
+ scope=Scope(),
202
+ payload={"title": "IntentForge summary", "metrics": metrics, "public_safe": True},
203
+ )
204
+
205
+
206
+ class IntentForgeSource:
207
+ """An :class:`EventSource` that adapts an IntentForge ndjson stream.
208
+
209
+ Reads raw IF ndjson (a file path, a line iterable, or — via
210
+ :meth:`from_command` — a spawned subprocess) and yields adapted canonical
211
+ envelope mappings, including the trailing summary. Malformed lines surface
212
+ through stream health; unmapped records are skipped.
213
+ """
214
+
215
+ def __init__(
216
+ self,
217
+ source: Path | str | Iterable[str] | AsyncIterable[str],
218
+ *,
219
+ run_id: str = "intentforge",
220
+ ) -> None:
221
+ self._source = source
222
+ self._run_id = run_id
223
+ self._cmd: tuple[str, ...] | None = None
224
+
225
+ @classmethod
226
+ def from_command(cls, cmd: Sequence[str], *, run_id: str = "intentforge") -> IntentForgeSource:
227
+ """Spawn ``cmd`` and adapt its stdout ndjson live."""
228
+ self = cls([], run_id=run_id)
229
+ self._cmd = tuple(cmd)
230
+ return self
231
+
232
+ async def _lines(self) -> AsyncIterator[str]:
233
+ if self._cmd is not None:
234
+ async for line in _aiter_subprocess_lines(self._cmd):
235
+ yield line
236
+ else:
237
+ async for line in _aiter_text_lines(self._source):
238
+ yield line
239
+
240
+ async def __aiter__(self) -> AsyncIterator[Mapping[str, Any]]:
241
+ import json
242
+
243
+ line_number = 0
244
+ async for line in self._lines():
245
+ line_number += 1
246
+ stripped = line.strip()
247
+ if not stripped:
248
+ continue
249
+ try:
250
+ record: Any = json.loads(stripped)
251
+ except json.JSONDecodeError as exc:
252
+ yield {"__malformed__": f"invalid JSON: {exc}", "line_number": line_number}
253
+ continue
254
+ if not isinstance(record, dict):
255
+ yield {"__malformed__": "line is not a JSON object", "line_number": line_number}
256
+ continue
257
+ event = adapt_record(record, run_id=self._run_id)
258
+ if event is not None:
259
+ yield event.to_mapping()