capstan 1.0.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Julien Hadley Jack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
capstan-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: capstan
3
+ Version: 1.0.0
4
+ Summary: A webhook inspector for local development
5
+ Author: Julien Hadley Jack
6
+ Author-email: Julien Hadley Jack <git@jlhj.de>
7
+ License-Expression: MIT
8
+ License-File: LICENSE.txt
9
+ Requires-Dist: aiohttp>=3.13.5
10
+ Requires-Dist: cyclopts>=4.10.0
11
+ Requires-Dist: textual>=8.1.1
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Capstan: The Webhook Inspector
16
+
17
+ <!-- [[[cog
18
+ from pathlib import Path
19
+ from scripts.cog_readme import copy_image
20
+ copy_image(
21
+ Path("tests/__snapshots__/test_tui_app/test_snapshot_populated.raw"),
22
+ Path("docs/screenshot.svg"),
23
+ )
24
+ ]]] -->
25
+ <!-- file checksum: 7122778bb8772c8540cb272658e2a897cc33e7313bc034f4884beaa1be9bd497 -->
26
+ ![](docs/screenshot.svg)
27
+ <!-- [[[end]]] -->
28
+
29
+ **Capstan** is a modern, lightweight, and terminal-based webhook inspector for local development. It provides a TUI (
30
+ Terminal User Interface) to monitor and inspect incoming HTTP requests, making it the perfect companion for debugging
31
+ webhooks and API integrations without leaving your terminal.
32
+
33
+ ## Features
34
+
35
+ - **Overview**: Quick view of method, path, host, and timestamp.
36
+ - **Headers & Cookies**: Complete breakdown of all request headers and cookies.
37
+ - **Query Parameters**: Easy-to-read table of all URL parameters.
38
+ - **Body Analysis**:
39
+ - **Syntax Highlighting**: Built-in support for JSON, XML, and more.
40
+ - **Hexdump**: Automatic fallback to a polished hexdump for binary or unknown content.
41
+
42
+ Capstan is designed to be a simple and lightweight tool. As such, it does **not** support:
43
+
44
+ - **Persistence**: Captured requests are stored in-memory and are lost when the application is closed.
45
+ - **HTTPS/SSL**: The local server only supports HTTP. Use a tunnel like `ngrok` if you need to receive webhooks from
46
+ HTTPS sources or from the internet.
47
+ - **Dynamic Responses**: It returns a static response (status, body, headers) for all requests.
48
+ - **Request Replay**: There is no functionality to "replay" or "resend" captured requests.
49
+
50
+ [mitmproxy](https://www.mitmproxy.org/) can be used instead should those advanced features be needed.
51
+
52
+ ## Installation
53
+
54
+ You can install Capstan using `uv` (recommended) :
55
+
56
+ ```bash
57
+ uv tool install capstan
58
+ ```
59
+
60
+ Alternatively, you could install it with `pipx`:
61
+
62
+ ```bash
63
+ pipx install capstan
64
+ ```
65
+
66
+ ## Quick Start
67
+
68
+ Simply run `capstan` to start the inspector:
69
+
70
+ ```bash
71
+ capstan
72
+ ```
73
+
74
+ By default, Capstan will start a server on `http://127.0.0.1:8080`.
75
+ You can now send requests to this address (or any path under it),
76
+ and they will appear in the TUI.
77
+
78
+ You can send then HTTP requests to that address with arbitrary paths. Example:
79
+
80
+ ```bash
81
+ curl -X POST http://127.0.0.1:8080/test-webhook \
82
+ -H "Content-Type: application/json" \
83
+ -d '{"event": "user_signup", "user_id": 123}'
84
+ ```
85
+
86
+ ## Usage
87
+
88
+ See the help documentation for more information:
89
+
90
+ <!-- [[[cog
91
+ from pathlib import Path
92
+ from scripts.cog_readme import run_command
93
+ run_command("capstan --help")
94
+ ]]] -->
95
+ ```bash
96
+ $ capstan --help
97
+ Usage: capstan COMMAND [OPTIONS]
98
+
99
+ A webhook inspector for local development.
100
+
101
+ ╭─ Commands ───────────────────────────────────────────────────────────────────╮
102
+ │ --help (-h) Display this message and exit. │
103
+ │ --install-completion Install shell completion for this application. │
104
+ │ --version Display application version. │
105
+ ╰──────────────────────────────────────────────────────────────────────────────╯
106
+ ╭─ Parameters ─────────────────────────────────────────────────────────────────╮
107
+ │ --host The listen address for the endpoint [default: 127.0.0.1] │
108
+ │ --port The port for the endpoint [default: 8080] │
109
+ │ --status The response status code for the webhook [default: 201] │
110
+ ╰──────────────────────────────────────────────────────────────────────────────╯
111
+ ```
112
+ <!-- [[[end]]] -->
@@ -0,0 +1,98 @@
1
+ # Capstan: The Webhook Inspector
2
+
3
+ <!-- [[[cog
4
+ from pathlib import Path
5
+ from scripts.cog_readme import copy_image
6
+ copy_image(
7
+ Path("tests/__snapshots__/test_tui_app/test_snapshot_populated.raw"),
8
+ Path("docs/screenshot.svg"),
9
+ )
10
+ ]]] -->
11
+ <!-- file checksum: 7122778bb8772c8540cb272658e2a897cc33e7313bc034f4884beaa1be9bd497 -->
12
+ ![](docs/screenshot.svg)
13
+ <!-- [[[end]]] -->
14
+
15
+ **Capstan** is a modern, lightweight, and terminal-based webhook inspector for local development. It provides a TUI (
16
+ Terminal User Interface) to monitor and inspect incoming HTTP requests, making it the perfect companion for debugging
17
+ webhooks and API integrations without leaving your terminal.
18
+
19
+ ## Features
20
+
21
+ - **Overview**: Quick view of method, path, host, and timestamp.
22
+ - **Headers & Cookies**: Complete breakdown of all request headers and cookies.
23
+ - **Query Parameters**: Easy-to-read table of all URL parameters.
24
+ - **Body Analysis**:
25
+ - **Syntax Highlighting**: Built-in support for JSON, XML, and more.
26
+ - **Hexdump**: Automatic fallback to a polished hexdump for binary or unknown content.
27
+
28
+ Capstan is designed to be a simple and lightweight tool. As such, it does **not** support:
29
+
30
+ - **Persistence**: Captured requests are stored in-memory and are lost when the application is closed.
31
+ - **HTTPS/SSL**: The local server only supports HTTP. Use a tunnel like `ngrok` if you need to receive webhooks from
32
+ HTTPS sources or from the internet.
33
+ - **Dynamic Responses**: It returns a static response (status, body, headers) for all requests.
34
+ - **Request Replay**: There is no functionality to "replay" or "resend" captured requests.
35
+
36
+ [mitmproxy](https://www.mitmproxy.org/) can be used instead should those advanced features be needed.
37
+
38
+ ## Installation
39
+
40
+ You can install Capstan using `uv` (recommended) :
41
+
42
+ ```bash
43
+ uv tool install capstan
44
+ ```
45
+
46
+ Alternatively, you could install it with `pipx`:
47
+
48
+ ```bash
49
+ pipx install capstan
50
+ ```
51
+
52
+ ## Quick Start
53
+
54
+ Simply run `capstan` to start the inspector:
55
+
56
+ ```bash
57
+ capstan
58
+ ```
59
+
60
+ By default, Capstan will start a server on `http://127.0.0.1:8080`.
61
+ You can now send requests to this address (or any path under it),
62
+ and they will appear in the TUI.
63
+
64
+ You can send then HTTP requests to that address with arbitrary paths. Example:
65
+
66
+ ```bash
67
+ curl -X POST http://127.0.0.1:8080/test-webhook \
68
+ -H "Content-Type: application/json" \
69
+ -d '{"event": "user_signup", "user_id": 123}'
70
+ ```
71
+
72
+ ## Usage
73
+
74
+ See the help documentation for more information:
75
+
76
+ <!-- [[[cog
77
+ from pathlib import Path
78
+ from scripts.cog_readme import run_command
79
+ run_command("capstan --help")
80
+ ]]] -->
81
+ ```bash
82
+ $ capstan --help
83
+ Usage: capstan COMMAND [OPTIONS]
84
+
85
+ A webhook inspector for local development.
86
+
87
+ ╭─ Commands ───────────────────────────────────────────────────────────────────╮
88
+ │ --help (-h) Display this message and exit. │
89
+ │ --install-completion Install shell completion for this application. │
90
+ │ --version Display application version. │
91
+ ╰──────────────────────────────────────────────────────────────────────────────╯
92
+ ╭─ Parameters ─────────────────────────────────────────────────────────────────╮
93
+ │ --host The listen address for the endpoint [default: 127.0.0.1] │
94
+ │ --port The port for the endpoint [default: 8080] │
95
+ │ --status The response status code for the webhook [default: 201] │
96
+ ╰──────────────────────────────────────────────────────────────────────────────╯
97
+ ```
98
+ <!-- [[[end]]] -->
@@ -0,0 +1,77 @@
1
+ [project]
2
+ name = "capstan"
3
+ version = "1.0.0"
4
+ description = "A webhook inspector for local development"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Julien Hadley Jack", email = "git@jlhj.de" }
8
+ ]
9
+ license = "MIT"
10
+ license-files = [
11
+ "LICENSE.txt",
12
+ ]
13
+ requires-python = ">=3.11"
14
+ dependencies = [
15
+ "aiohttp>=3.13.5",
16
+ "cyclopts>=4.10.0",
17
+ "textual>=8.1.1",
18
+ ]
19
+
20
+ [project.scripts]
21
+ capstan = "capstan.cli:app"
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.11.1,<0.12.0"]
25
+ build-backend = "uv_build"
26
+
27
+ [dependency-groups]
28
+ build = [
29
+ "python-semantic-release>=10.5.3",
30
+ ]
31
+ dev = [
32
+ "cogapp>=3.6.0",
33
+ "pytest>=9.0.2",
34
+ "pytest-asyncio>=1.3.0",
35
+ "pytest-cov>=7.0.0",
36
+ "pytest-github-report>=0.0.1",
37
+ "pytest-textual-snapshot>=1.0.0",
38
+ "ruff>=0.15.7",
39
+ "textual-dev>=1.8.0",
40
+ ]
41
+
42
+ [tool.uv]
43
+ required-version = ">=0.9.6"
44
+
45
+ [tool.ruff]
46
+ line-length = 120
47
+
48
+ [tool.semantic_release]
49
+ version_toml = ["pyproject.toml:project.version"]
50
+ build_command = """
51
+ uv run cog -r README.md
52
+ uv lock --upgrade-package "$PACKAGE_NAME"
53
+ git add README.md uv.lock
54
+ uv build
55
+ """
56
+
57
+ [tool.semantic_release.changelog]
58
+ exclude_commit_patterns = [
59
+ '''chore(?:\([^)]*?\))?: .+''',
60
+ '''ci(?:\([^)]*?\))?: .+''',
61
+ '''refactor(?:\([^)]*?\))?: .+''',
62
+ '''style(?:\([^)]*?\))?: .+''',
63
+ '''test(?:\([^)]*?\))?: .+''',
64
+ '''docs(?:\([^)]*?\))?: .+''',
65
+ '''build(?:\([^)]*?\))?: .+''',
66
+ '''build\((?!deps\): .+)''',
67
+ ]
68
+
69
+ [semantic_release.changelog.default_templates]
70
+ changelog_file = "CHANGELOG.md"
71
+
72
+ [tool.pytest.ini_options]
73
+ asyncio_mode = "auto"
74
+ addopts = ["--snapshot-patch-pycharm-diff"]
75
+ markers = [
76
+ "capstan(app): Allows you to configure capstan-related settings.",
77
+ ]
File without changes
@@ -0,0 +1,34 @@
1
+
2
+ from cyclopts import App
3
+
4
+ from capstan.model import ResponseConfig, ServerConfig
5
+ from capstan.tui import TUIApp
6
+
7
+ app = App(name="capstan", help="A webhook inspector for local development.")
8
+ app.register_install_completion_command()
9
+
10
+
11
+ @app.default
12
+ def run(
13
+ *,
14
+ host: str = ServerConfig.host,
15
+ port: int = ServerConfig.port,
16
+ status: int = ResponseConfig.status,
17
+ ):
18
+ """
19
+ :param port: The port for the endpoint
20
+ :param host: The listen address for the endpoint
21
+ :param status: The response status code for the webhook
22
+ """
23
+ server_config = ServerConfig(
24
+ host=host,
25
+ port=port,
26
+ response=ResponseConfig(status=status),
27
+ )
28
+
29
+ tui_app = TUIApp(server_config=server_config)
30
+ tui_app.run()
31
+
32
+
33
+ if __name__ == "__main__":
34
+ app()
@@ -0,0 +1,46 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass, field
3
+ from datetime import datetime
4
+ from typing import Any, Coroutine, Mapping
5
+
6
+ from aiohttp import HttpVersion
7
+ from multidict import MultiMapping, MultiDict
8
+
9
+
10
+ @dataclass
11
+ class RequestData(object):
12
+ timestamp: datetime
13
+ method: str
14
+ path: str
15
+ headers: MultiMapping[str]
16
+ body: bytes
17
+ query_params: MultiMapping[str]
18
+ host: str
19
+ http_version: HttpVersion
20
+ cookies: Mapping[str, str]
21
+
22
+ @property
23
+ def content_type(self) -> str:
24
+ return self.headers.get("Content-Type", "").split(";")[0].strip().lower()
25
+
26
+
27
+ @dataclass
28
+ class ResponseConfig(object):
29
+ status: int = 201
30
+ headers: MultiMapping[str] = field(default_factory=lambda: MultiDict({"content-type": "application/json"}))
31
+ body: bytes = '{"status": "received"}'.encode()
32
+
33
+
34
+ @dataclass
35
+ class ServerConfig(object):
36
+ host: str = "127.0.0.1"
37
+ port: int = 8080
38
+ response: ResponseConfig = field(default_factory=lambda: ResponseConfig())
39
+
40
+ @property
41
+ def listen_address(self) -> str:
42
+ # noinspection HttpUrlsUsage
43
+ return f"http://{self.host}:{self.port}"
44
+
45
+
46
+ RequestCallback = Callable[[RequestData], Coroutine[Any, Any, None]]
@@ -0,0 +1,70 @@
1
+ from datetime import datetime
2
+
3
+ from aiohttp import web
4
+
5
+ from capstan.model import RequestCallback, RequestData, ServerConfig
6
+
7
+
8
+ class WebhookServer:
9
+ def __init__(self, config: ServerConfig, callback: RequestCallback):
10
+ self.config = config
11
+ self.callback = callback
12
+ self.runner = None
13
+
14
+ async def handle_request(self, request: web.Request) -> web.Response:
15
+ request_data = RequestData(
16
+ timestamp=datetime.now(),
17
+ method=request.method,
18
+ path=request.url.path,
19
+ headers=request.headers,
20
+ body=await request.read(),
21
+ query_params=request.query,
22
+ host=request.host,
23
+ http_version=request.version,
24
+ cookies=request.cookies,
25
+ )
26
+
27
+ await self.callback(request_data)
28
+
29
+ return web.Response(
30
+ status=self.config.response.status,
31
+ body=self.config.response.body,
32
+ headers=self.config.response.headers,
33
+ )
34
+
35
+ async def start(self):
36
+ """Starts the aiohttp server within the provided event loop."""
37
+ app = web.Application()
38
+ app.router.add_route("*", "/{tail:.*}", self.handle_request)
39
+
40
+ self.runner = web.AppRunner(app)
41
+ await self.runner.setup()
42
+ site = web.TCPSite(self.runner, self.config.host, self.config.port)
43
+ await site.start()
44
+
45
+ async def stop(self):
46
+ """Stops the server and cleans up resources."""
47
+ if self.runner:
48
+ await self.runner.cleanup()
49
+
50
+
51
+ async def _main(): # pragma: no cover
52
+ async def print_result(request: RequestData):
53
+ print(request)
54
+
55
+ config = ServerConfig(host="0.0.0.0", port=8085)
56
+
57
+ server = WebhookServer(config=config, callback=print_result)
58
+ await server.start()
59
+ print(f"Started web server at {config.listen_address}.")
60
+
61
+ try:
62
+ await asyncio.Event().wait()
63
+ except asyncio.CancelledError:
64
+ await server.stop()
65
+
66
+
67
+ if __name__ == "__main__": # pragma: no cover
68
+ import asyncio
69
+
70
+ asyncio.run(_main())
@@ -0,0 +1,117 @@
1
+ from datetime import datetime
2
+
3
+ from aiohttp import HttpVersion11
4
+ from multidict import MultiDict
5
+ from textual.app import App, ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.containers import Horizontal, ScrollableContainer
8
+ from textual.reactive import reactive
9
+ from textual.widgets import Footer, Header
10
+
11
+ from capstan.model import RequestData, ServerConfig
12
+ from capstan.server import WebhookServer
13
+ from capstan.widgets.request_details import RequestDetails
14
+ from capstan.widgets.sidebar import Sidebar
15
+ from capstan.widgets.status_bar import StatusBar
16
+
17
+
18
+ class TUIApp(App):
19
+ TITLE = "Capstan"
20
+ SUB_TITLE = "The webhook inspector"
21
+
22
+ BINDINGS = [
23
+ Binding("r", "reset_list", "Reset list"),
24
+ Binding("c", "copy_body", "Copy body"),
25
+ Binding("a", "copy_listen_address", "Copy address"),
26
+ Binding("f", "follow_off", "Auto follow (on)"),
27
+ Binding("f", "follow_on", "Auto follow (off)"),
28
+ Binding("q", "quit", "Quit"),
29
+ Binding("t", "test_request", "Create a test request", show=False),
30
+ ]
31
+
32
+ CSS_PATH = "tui.tcss"
33
+
34
+ selected_request: reactive[RequestData | None] = reactive(None)
35
+ auto_follow: reactive[bool] = reactive(True)
36
+
37
+ def __init__(self, server_config: ServerConfig):
38
+ super().__init__()
39
+ self.server_config = server_config
40
+ self.server = WebhookServer(config=server_config, callback=self.add_request)
41
+
42
+ def compose(self) -> ComposeResult:
43
+ yield Header()
44
+ with Horizontal():
45
+ yield Sidebar().data_bind(TUIApp.auto_follow)
46
+ with ScrollableContainer():
47
+ yield RequestDetails().data_bind(request=TUIApp.selected_request)
48
+ yield StatusBar(self.server_config.listen_address)
49
+ yield Footer()
50
+
51
+ async def on_mount(self) -> None:
52
+ """Starts the webhook server when the application is mounted."""
53
+ await self.server.start()
54
+
55
+ async def on_unmount(self) -> None:
56
+ """Gracefully shuts down the server when the application is closed."""
57
+ await self.server.stop()
58
+
59
+ async def action_test_request(self): # pragma: no cover
60
+ request = RequestData(
61
+ timestamp=datetime.now(),
62
+ method="POST",
63
+ path="/",
64
+ headers=MultiDict(),
65
+ body="".encode(),
66
+ query_params=MultiDict(),
67
+ host="127.0.0.1",
68
+ http_version=HttpVersion11,
69
+ cookies=dict(),
70
+ )
71
+ await self.add_request(request)
72
+
73
+ def action_reset_list(self):
74
+ self.query_one(Sidebar).clear()
75
+ self.selected_request = None
76
+ self.refresh_bindings()
77
+
78
+ def action_copy_body(self):
79
+ if not self.selected_request or not self.selected_request.body:
80
+ return
81
+ try:
82
+ self.copy_to_clipboard(self.selected_request.body.decode("utf-8"))
83
+ except UnicodeDecodeError:
84
+ self.notify("Can't copy body content", title="Copy to clipboard failed!")
85
+
86
+ def action_copy_listen_address(self):
87
+ self.copy_to_clipboard(self.server_config.listen_address)
88
+
89
+ async def add_request(self, request: RequestData):
90
+ await self.query_one(Sidebar).add_request(request)
91
+
92
+ def on_sidebar_request_selected(self, event: Sidebar.RequestSelected) -> None:
93
+ self.selected_request = event.request
94
+ self.refresh_bindings()
95
+
96
+ def action_follow_on(self):
97
+ self.auto_follow = True
98
+ self.refresh_bindings()
99
+
100
+ def action_follow_off(self):
101
+ self.auto_follow = False
102
+ self.refresh_bindings()
103
+
104
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
105
+ if action == "follow_on" and self.auto_follow:
106
+ return False
107
+ if action == "follow_off" and not self.auto_follow:
108
+ return False
109
+ if action == "copy_body" and (not self.selected_request or not self.selected_request.body):
110
+ return False
111
+ else:
112
+ return True
113
+
114
+
115
+ if __name__ == "__main__": # pragma: no cover
116
+ app = TUIApp(server_config=ServerConfig())
117
+ app.run()
@@ -0,0 +1,17 @@
1
+ Sidebar {
2
+ width: 30%;
3
+ height: 100%;
4
+ border-right: solid $accent;
5
+ }
6
+
7
+ RequestDetails {
8
+ padding: 1;
9
+ }
10
+
11
+ StatusBar {
12
+ background: $accent;
13
+ color: $text;
14
+ height: 1;
15
+ width: 100%;
16
+ content-align: center middle;
17
+ }
@@ -0,0 +1,17 @@
1
+ def get_syntax_lexer(content_type: str) -> str | None:
2
+ """
3
+ Get a pygmentize lexer based on a Content-Type header
4
+ """
5
+ if "json" in content_type:
6
+ return "json"
7
+ if "xml" in content_type:
8
+ return "xml"
9
+ if "text" in content_type:
10
+ return "text"
11
+ if "form-urlencoded" in content_type:
12
+ return "urlencoded"
13
+ if "multipart/" in content_type:
14
+ return "mime"
15
+ if "application/octet-stream" in content_type:
16
+ return None
17
+ return "text"
File without changes
@@ -0,0 +1,56 @@
1
+ from rich.markup import escape
2
+ from rich.panel import Panel
3
+ from rich.syntax import Syntax
4
+ from rich.table import Table
5
+ from textual.app import ComposeResult
6
+ from textual.reactive import reactive
7
+ from textual.widgets import Static
8
+
9
+ from capstan.model import RequestData
10
+ from capstan.util import get_syntax_lexer
11
+
12
+
13
+ class RequestBody(Static):
14
+ BINARY_SIZE_LIMIT = 5 * 1024 # 5KB limit for performance
15
+ BINARY_ROW_SIZE = 16
16
+
17
+ request: reactive[RequestData] = reactive(None, recompose=True)
18
+ show_line_numbers: reactive[bool] = reactive(True, recompose=True)
19
+
20
+ def _hexdump(self, data: bytes) -> Table:
21
+ """Generates a polished hexdump of the given bytes using a Rich Table."""
22
+ table = Table(show_header=True, header_style="bold magenta", box=None, padding=(0, 1))
23
+ table.add_column("Offset", style="dim", justify="right")
24
+ table.add_column("Hex", style="green")
25
+ table.add_column("ASCII", style="cyan")
26
+
27
+ display_data = data[:self.BINARY_SIZE_LIMIT]
28
+
29
+ for i in range(0, len(display_data), self.BINARY_ROW_SIZE):
30
+ chunk = display_data[i : i + self.BINARY_ROW_SIZE]
31
+ hex_part = " ".join(f"{b:02x}" for b in chunk)
32
+ ascii_part = "".join(chr(b) if 32 <= b <= 126 else "." for b in chunk)
33
+ table.add_row(f"{i:08x}", hex_part, escape(ascii_part))
34
+
35
+ if len(data) > self.BINARY_SIZE_LIMIT:
36
+ table.add_row("...", f"({len(data) - self.BINARY_SIZE_LIMIT} more bytes truncated)", "...")
37
+
38
+ return table
39
+
40
+ def compose(self) -> ComposeResult:
41
+ if not self.request or not self.request.body:
42
+ return
43
+
44
+ content_type = self.request.content_type
45
+ lexer = get_syntax_lexer(content_type)
46
+ body = self.request.body
47
+
48
+ if lexer:
49
+ try:
50
+ decoded_body = body.decode("utf-8")
51
+ syntax = Syntax(decoded_body, lexer, word_wrap=True, line_numbers=self.show_line_numbers)
52
+ yield Static(Panel(syntax, title=f"Body ({content_type or 'text: unknown'})"))
53
+ except UnicodeDecodeError:
54
+ yield Static(Panel(self._hexdump(body), title="Body (binary: unknown)"))
55
+ else:
56
+ yield Static(Panel(self._hexdump(body), title=f"Body ({content_type or 'binary: unknown'})"))
@@ -0,0 +1,63 @@
1
+ from rich import box
2
+ from rich.panel import Panel
3
+ from rich.table import Table
4
+ from textual.app import ComposeResult
5
+ from textual.reactive import reactive
6
+ from textual.widget import Widget
7
+ from textual.widgets import Static
8
+
9
+ from capstan.model import RequestData
10
+ from capstan.widgets.request_body import RequestBody
11
+
12
+
13
+ class RequestDetails(Static):
14
+ request: reactive[RequestData] = reactive(None, recompose=True)
15
+
16
+ def compose(self) -> ComposeResult:
17
+ if not self.request:
18
+ return
19
+
20
+ yield self.overview_panel()
21
+ if self.request.query_params:
22
+ yield self.query_panel()
23
+ if self.request.headers:
24
+ yield self.header_panel()
25
+ if self.request.cookies:
26
+ yield self.cookie_panel()
27
+ if self.request.body:
28
+ yield RequestBody().data_bind(RequestDetails.request)
29
+
30
+ def overview_panel(self) -> Widget:
31
+ timestamp = self.request.timestamp.strftime("%Y-%m-%d %H:%M:%S.%f")
32
+ rows = [
33
+ ("Method", self.request.method),
34
+ ("Path", self.request.path),
35
+ ("Host", self.request.host),
36
+ ("Timestamp", timestamp),
37
+ ]
38
+
39
+ table = self._create_table("Key", "Value", rows, show_header=False)
40
+ return Static(Panel(table, title="Overview"))
41
+
42
+ def query_panel(self) -> Widget:
43
+ table = self._create_table("Query", "Value", self.request.query_params.items())
44
+ return Static(Panel(table, title="Query params"))
45
+
46
+ def cookie_panel(self) -> Widget:
47
+ table = self._create_table("Parameter", "Value", self.request.cookies.items())
48
+ return Static(Panel(table, title="Cookies"))
49
+
50
+ def header_panel(self) -> Widget:
51
+ table = self._create_table("Header", "Value", self.request.headers.items())
52
+ return Static(Panel(table, title="Headers"))
53
+
54
+ @staticmethod
55
+ def _create_table(header_1: str, header_2: str, rows: list[tuple[str, str]], show_header: bool = True) -> Table:
56
+ table = Table(show_header=show_header, box=box.SIMPLE, expand=True)
57
+ table.add_column(header_1, style="cyan", overflow="fold", ratio=1)
58
+ table.add_column(header_2, overflow="fold", ratio=3)
59
+
60
+ for key, value in rows:
61
+ table.add_row(key, str(value))
62
+
63
+ return table
@@ -0,0 +1,19 @@
1
+ from textual.app import ComposeResult
2
+ from textual.widgets import Label, ListItem
3
+
4
+ from capstan.model import RequestData
5
+
6
+
7
+ class RequestItem(ListItem):
8
+ def __init__(self, request: RequestData):
9
+ super().__init__()
10
+ self.request = request
11
+
12
+ def compose(self) -> ComposeResult:
13
+ method = self.request.method
14
+ path = self.request.path
15
+ timestamp = self.request.timestamp
16
+ time_display = timestamp.strftime("%H:%M:%S")
17
+
18
+ yield Label(f"[bold cyan]{method}[/] {path}")
19
+ yield Label(time_display, classes="timestamp")
@@ -0,0 +1,32 @@
1
+ from textual.message import Message
2
+ from textual.reactive import reactive
3
+ from textual.widgets import ListView
4
+
5
+ from capstan.model import RequestData
6
+ from capstan.widgets.request_item import RequestItem
7
+
8
+
9
+ class Sidebar(ListView):
10
+ auto_follow: reactive[bool] = reactive(True)
11
+
12
+ async def add_request(self, request: RequestData):
13
+ old_index = self.index
14
+ self.index = None
15
+
16
+ new_item = RequestItem(request)
17
+ await self.insert(0, [new_item])
18
+
19
+ if self.auto_follow or old_index is None:
20
+ self.index = 0
21
+ else:
22
+ self.index = old_index + 1
23
+
24
+ def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
25
+ if isinstance(event.item, RequestItem):
26
+ self.post_message(self.RequestSelected(event.item))
27
+
28
+ class RequestSelected(Message):
29
+ def __init__(self, item: RequestItem) -> None:
30
+ super().__init__()
31
+ self.request: RequestData = item.request if item else None
32
+ self.item: RequestItem | None = item
@@ -0,0 +1,18 @@
1
+ from textual.widgets import Label
2
+
3
+
4
+ class StatusBar(Label):
5
+ """A clickable status bar that copies the listen address to clipboard."""
6
+
7
+ def __init__(self, listen_address: str):
8
+ super().__init__()
9
+ self.listen_address = listen_address
10
+ self.content = self.original_message = f"Listening on {self.listen_address} (click to copy)"
11
+
12
+ def on_click(self) -> None:
13
+ self.app.copy_to_clipboard(self.listen_address)
14
+ self.content = f"Copied {self.listen_address} to clipboard!"
15
+ self.set_timer(3.0, self.reset_message)
16
+
17
+ def reset_message(self) -> None:
18
+ self.content = self.original_message