ros2-term 0.1.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.
Files changed (34) hide show
  1. ros2_term-0.1.0/.gitignore +14 -0
  2. ros2_term-0.1.0/.old/app.css +76 -0
  3. ros2_term-0.1.0/.old/config.yaml +3 -0
  4. ros2_term-0.1.0/.old/node_test.py +19 -0
  5. ros2_term-0.1.0/.old/ros2_explorer.py +326 -0
  6. ros2_term-0.1.0/CHANGELOG.md +126 -0
  7. ros2_term-0.1.0/PKG-INFO +113 -0
  8. ros2_term-0.1.0/README.md +86 -0
  9. ros2_term-0.1.0/pyproject.toml +61 -0
  10. ros2_term-0.1.0/src/ros2_term/__init__.py +0 -0
  11. ros2_term-0.1.0/src/ros2_term/app.css +76 -0
  12. ros2_term-0.1.0/src/ros2_term/app.py +558 -0
  13. ros2_term-0.1.0/src/ros2_term/lib/__init__.py +0 -0
  14. ros2_term-0.1.0/src/ros2_term/lib/config.py +90 -0
  15. ros2_term-0.1.0/src/ros2_term/lib/entity.py +259 -0
  16. ros2_term-0.1.0/src/ros2_term/lib/history.py +62 -0
  17. ros2_term-0.1.0/src/ros2_term/lib/messages.py +43 -0
  18. ros2_term-0.1.0/src/ros2_term/lib/ros2_client.py +438 -0
  19. ros2_term-0.1.0/src/ros2_term/lib/topic_watcher.py +281 -0
  20. ros2_term-0.1.0/src/ros2_term/ui/__init__.py +0 -0
  21. ros2_term-0.1.0/src/ros2_term/ui/help_screen.py +161 -0
  22. ros2_term-0.1.0/src/ros2_term/ui/profile_screen.py +86 -0
  23. ros2_term-0.1.0/src/ros2_term/ui/screens.py +93 -0
  24. ros2_term-0.1.0/src/ros2_term/ui/search_bar.py +140 -0
  25. ros2_term-0.1.0/src/ros2_term/ui/widgets.py +1602 -0
  26. ros2_term-0.1.0/test/node/dummy_node1.py +36 -0
  27. ros2_term-0.1.0/test/node/dummy_node2.py +32 -0
  28. ros2_term-0.1.0/test/run_app.sh +2 -0
  29. ros2_term-0.1.0/test/test.py +10 -0
  30. ros2_term-0.1.0/test/test_client.py +25 -0
  31. ros2_term-0.1.0/test/test_interfaces.py +134 -0
  32. ros2_term-0.1.0/test/test_node.py +51 -0
  33. ros2_term-0.1.0/test/test_ros2_client.py +8 -0
  34. ros2_term-0.1.0/uv.lock +191 -0
@@ -0,0 +1,14 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+ .claude
9
+ .vscode
10
+
11
+ # Virtual environments
12
+ .venv
13
+
14
+
@@ -0,0 +1,76 @@
1
+ Screen {
2
+ layout: vertical;
3
+ }
4
+
5
+ /* Multi-screen layout */
6
+ .container {
7
+ height: 100%;
8
+ background: $panel;
9
+ }
10
+
11
+ #left-panel-container {
12
+ width: 50%;
13
+ }
14
+
15
+ NodeTreePanel, TopicTreePanel, RosEntityListPanel {
16
+ padding-left: 2;
17
+ width: 100%;
18
+ }
19
+
20
+ RosEntityInfoPanel, RosTypeDefinitionPanel {
21
+ padding: 1 2;
22
+ }
23
+
24
+ #main {
25
+ width: 70%;
26
+ border-left: inner $primary;
27
+ }
28
+
29
+ #main-upper {
30
+ border-bottom: inner $primary;
31
+ }
32
+
33
+ .main-half {
34
+ height: 50%;
35
+ }
36
+
37
+ /* ------------------------------------------------- */
38
+ SearchBar {
39
+ max-width: 100%;
40
+ height: 5;
41
+ padding: 0 1;
42
+ border: solid green;
43
+ }
44
+
45
+ SearchBar Input {
46
+ width: 1fr;
47
+ min-width: 10;
48
+ }
49
+
50
+ SearchBar .search-button {
51
+ width: 4;
52
+ margin-left: 1;
53
+ min-width: 4;
54
+ text-style: bold;
55
+ }
56
+
57
+ SearchBar .search-button.hover {
58
+ background: $surface;
59
+ }
60
+
61
+ SearchBar .search-button.active {
62
+ background: $secondary;
63
+ text-style: bold;
64
+ }
65
+
66
+ /* ------------------------------------------------- */
67
+
68
+ #close-button {
69
+ width: 30;
70
+ margin: 1;
71
+ color: white;
72
+ }
73
+
74
+ #button-container {
75
+ align-horizontal: center;
76
+ }
@@ -0,0 +1,3 @@
1
+
2
+
3
+ refresh_interval: 5.0
@@ -0,0 +1,19 @@
1
+ import rclpy
2
+ from rclpy.node import Node
3
+
4
+ class NodeLister(Node):
5
+ def __init__(self):
6
+ super().__init__('node_lister')
7
+ # Get list of node names and namespaces
8
+ node_names = self.get_node_names_and_namespaces()
9
+ self.get_logger().info("Active nodes:")
10
+ for name, namespace in node_names:
11
+ self.get_logger().info(f"{namespace}/{name}")
12
+
13
+ def main():
14
+ rclpy.init()
15
+ node = NodeLister()
16
+ rclpy.shutdown()
17
+
18
+ if __name__ == '__main__':
19
+ main()
@@ -0,0 +1,326 @@
1
+ import subprocess
2
+ from textual.app import App, ComposeResult
3
+ from textual.widget import Widget
4
+ from textual.widgets import (
5
+ Header,
6
+ Footer,
7
+ Tree,
8
+ Label,
9
+ Markdown,
10
+ MarkdownViewer,
11
+ Input,
12
+ Button,
13
+ Tooltip,
14
+ Static
15
+ )
16
+ from textual.containers import Container, Horizontal
17
+ from textual.screen import ModalScreen, Screen
18
+ from textual.containers import Vertical
19
+ from textual.message import Message
20
+ from textual import events
21
+ from textual.widgets._tree import TreeNode
22
+ from textual.binding import Binding
23
+ from rich.text import Text
24
+ import re
25
+ import yaml
26
+
27
+ """TODO
28
+ 1. List count of nodes
29
+ 2.
30
+
31
+ # from remote_pdb import set_trace; set_trace(port=4444)
32
+ """
33
+
34
+ def run_ros2_command(cmd: list[str]) -> list[str]:
35
+ try:
36
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
37
+ return result.stdout.strip().splitlines()
38
+ except subprocess.CalledProcessError as e:
39
+ return [f"Error: {e}"]
40
+
41
+ class ToggleButton(Button):
42
+ def on_mount(self):
43
+ # Set initial state
44
+ self.state = False
45
+
46
+ def toggle(self):
47
+ # Toggle the state
48
+ self.state = True if self.state == False else False
49
+ self.log.info(f"State of {self.id} is {self.state}")
50
+ self.update_style()
51
+
52
+ def update_style(self):
53
+ # Change visual style based on state
54
+ if self.state == True:
55
+ self.add_class("active")
56
+ else:
57
+ self.remove_class("active")
58
+
59
+ def on_button_pressed(self, event: Button.Pressed) -> None:
60
+ if isinstance(event.button, ToggleButton):
61
+ event.button.toggle()
62
+
63
+
64
+ HELP_TEXT = """
65
+ # Help Menu
66
+
67
+ Welcome to the **ROS2 TUI App**! Press 'q' to exit the help.
68
+
69
+ ## Key Bindings
70
+
71
+ - `Ctrl+q`: Exit the app.
72
+ - `Tab/Shift+Tab`: Go to next/previous widget.
73
+ - `Space`: Expand a selection when in node/topic tree view.
74
+ - `Enter`: Go to link when a link is highlighted (or click on it directly).
75
+
76
+ ## Tips
77
+
78
+ - Search boxes use semicolon (;) as a delimiter so you can search for multiple node/topic names
79
+ at once.
80
+
81
+ ## Configuration
82
+
83
+ """
84
+
85
+ # Create the help screen as a modal
86
+ class HelpScreen(ModalScreen):
87
+ CSS_PATH="app.css"
88
+ def compose(self) -> ComposeResult:
89
+ yield Vertical(
90
+ Markdown(HELP_TEXT, id="help-markdown"),
91
+ Container(
92
+ Button("Close", id="close-button"),
93
+ id="button-container"
94
+ )
95
+ )
96
+
97
+ def on_button_pressed(self, event: Button.Pressed) -> None:
98
+ self.app.pop_screen()
99
+
100
+ def on_key(self, event: events.Key) -> None:
101
+ if event.key == "q":
102
+ self.app.pop_screen()
103
+
104
+
105
+ class Ros2NodeTree(Tree[str]):
106
+ """Tree widget to show ROS 2 nodes and their info."""
107
+
108
+ def __init__(self, *args, **kwargs):
109
+ super().__init__(*args, **kwargs)
110
+ self.nodes = []
111
+ self.filtered_out = []
112
+ self.search_text = ""
113
+
114
+ def on_mount(self) -> None:
115
+
116
+ with open("config.yaml", "r") as f:
117
+ self.config = yaml.safe_load(f)
118
+
119
+ interval = float(self.config['refresh_interval'])
120
+
121
+ self.root.label = "ROS 2 Nodes"
122
+ self.set_interval(interval, self.load_nodes)
123
+ self.load_nodes()
124
+
125
+ def get_ros_node_names(self):
126
+ """Get the name of the ros node names that exist in top layer of the tree"""
127
+ return [str(child.label) for child in self.root.children]
128
+
129
+ def get_child_node(self, name):
130
+ """Just looking at the top tree layer. Should ideally traverse the tree"""
131
+ for child in self.root.children:
132
+ if str(child.label) == name:
133
+ return child
134
+ return None
135
+
136
+
137
+ def load_nodes(self):
138
+ nodes = [x.strip() for x in run_ros2_command(["ros2", "node", "list"])]
139
+
140
+ node_msg = "\n".join(nodes)
141
+ if "error:" in node_msg.lower():
142
+ self.root.remove_children()
143
+ self.root.add_leaf(Text(node_msg, style="white on red"))
144
+ return
145
+
146
+ # Remove any existing children that are not in updated node list
147
+ for child in self.root.children:
148
+ if str(child.label) not in nodes:
149
+ child.remove()
150
+
151
+ # Do similar updates on list of nodes that are filtered out
152
+ self.filtered_out = [x for x in self.filtered_out if x in nodes]
153
+
154
+ # Add any missing children from updated node list
155
+ children = self.get_ros_node_names()
156
+ for node in nodes:
157
+ if node not in children:
158
+ self.root.add(Text(node.strip(), style="bold deep_sky_blue3"))
159
+
160
+ # Filter out any added nodes that don't meet the search criteria
161
+ self.filter_nodes()
162
+
163
+ self.root.expand()
164
+ self.nodes = nodes
165
+
166
+ def filter_nodes(self):
167
+ """From the search input, filter the shown ROS2 node names"""
168
+
169
+ case_button = self.screen.query_one("#case_insensitive", ToggleButton)
170
+ regex_button = self.screen.query_one("#regex_mode", ToggleButton)
171
+
172
+ if len(self.search_text) > 0:
173
+ search = self.search_text
174
+ case_insensitive = case_button.state
175
+ use_regex = regex_button.state
176
+ else:
177
+ search = ".*"
178
+ case_insensitive = False
179
+ use_regex = True
180
+
181
+ node_names = self.get_ros_node_names()
182
+ show = {}
183
+ for name in (node_names + self.filtered_out):
184
+ if use_regex:
185
+ flags = re.IGNORECASE if case_insensitive else 0
186
+ if re.search(search, name, flags):
187
+ show[name] = True
188
+ else:
189
+ show[name] = False
190
+ else:
191
+ if case_insensitive:
192
+ name = name.lower()
193
+ search = search.lower()
194
+ if search in name:
195
+ show[name] = True
196
+ else:
197
+ show[name] = False
198
+
199
+ for name in show:
200
+ if show[name] and name not in node_names:
201
+ self.root.add(Text(name, style="bold deep_sky_blue3"))
202
+ if name in self.filtered_out:
203
+ self.filtered_out.remove(name)
204
+
205
+ if not show[name] and name in node_names:
206
+ self.get_child_node(name).remove()
207
+ self.filtered_out.append(name)
208
+
209
+ # Sort node order
210
+ sorted_children = sorted(self.root.children, key=lambda child: str(child.label))
211
+ self.root._children = sorted_children
212
+
213
+
214
+ def on_key(self, event: events.Key) -> None:
215
+ if event.key:
216
+ pass
217
+
218
+ def parse_node_info(self, node_info: list[str]):
219
+ """Parse the output of 'ros2 node info <node>' """
220
+ key = None
221
+
222
+ target_keys = [
223
+ "Subscribers",
224
+ "Publishers",
225
+ "Service Servers",
226
+ "Service Clients",
227
+ "Action Servers",
228
+ "Action Clients",
229
+ ]
230
+ data = {k: [] for k in target_keys}
231
+
232
+ for line in node_info:
233
+ for k in target_keys:
234
+ if k in line:
235
+ key = k
236
+ break
237
+ else: # This else only executes if inner for loop completes
238
+ if key is not None and len(line.strip()) > 0:
239
+ data[key].append(line.strip())
240
+
241
+ return data
242
+
243
+ def add_node_info(self, parent: TreeNode, node_info: dict[str, list], expand=True):
244
+ parent.remove_children()
245
+ for key, value in node_info.items():
246
+ if len(value) > 0:
247
+ new_section = parent.add(Text(key, style="bold dark_turquoise"))
248
+ for v in value:
249
+ new_section.add_leaf(v)
250
+ new_section.expand()
251
+ # else:
252
+ # parent.add_leaf(f"No {key}")
253
+
254
+ def populate_node_info(self, selected_node: TreeNode):
255
+ node_name = selected_node.label
256
+ node_info = run_ros2_command(["ros2", "node", "info", str(node_name)])
257
+ self.log.info(node_info)
258
+
259
+ if not node_info or node_info[0].startswith("Error"):
260
+ selected_node.add_leaf(Text("Could not fetch node info", style="bold white on red"))
261
+ return
262
+
263
+ node_info_dict = self.parse_node_info(node_info)
264
+ self.add_node_info(selected_node, node_info_dict)
265
+
266
+
267
+ async def on_tree_node_expanded(self, event: Tree.NodeExpanded[str]) -> None:
268
+ selected_node = event.node
269
+ # from remote_pdb import set_trace; set_trace(port=4444)
270
+ if str(selected_node.label) in self.nodes:
271
+ self.populate_node_info(selected_node)
272
+
273
+
274
+ class SearchBar(Widget):
275
+ def compose(self) -> ComposeResult:
276
+ # Horizontal layout for inline arrangement
277
+ with Horizontal():
278
+ yield Input(placeholder="Search...", id="search_input") #, compact=True)
279
+
280
+ # 'i' button (case insensitive)
281
+ yield ToggleButton("i", id="case_insensitive", tooltip="case insensitive", classes="search-button")
282
+
283
+ # 're' button (regex)
284
+ yield ToggleButton("re", id="regex_mode", tooltip="regex matching", classes="search-button")
285
+
286
+ def on_input_changed(self, event: Input.Changed) -> None:
287
+
288
+ self.log(f"Search submitted: {event.value}")
289
+ tree = self.screen.query_one("#main_tree", Ros2NodeTree)
290
+ tree.search_text = event.value
291
+ tree.filter_nodes()
292
+
293
+
294
+ class Ros2TUI(App):
295
+
296
+ CSS_PATH = "app.css"
297
+
298
+ BINDINGS = [
299
+ ("h", "help", "Help"),
300
+ ("r", "refresh", "Refresh"),
301
+ ]
302
+
303
+ def compose(self) -> ComposeResult:
304
+ yield Header()
305
+ yield SearchBar()
306
+ with Container(id="main"):
307
+ yield Ros2NodeTree(id="main_tree", label="root")
308
+ yield Footer()
309
+
310
+ def on_mount(self) -> None:
311
+ # Focus the main_tree widget on startup
312
+ self.query_one("#main_tree").focus()
313
+
314
+ def action_help(self):
315
+ self.console.log("Help requested")
316
+ self.push_screen(HelpScreen())
317
+
318
+ def action_refresh(self):
319
+ self.console.log("Refresh requested")
320
+ tree = self.screen.query_one("#main_tree", Ros2NodeTree)
321
+ tree.load_nodes()
322
+
323
+
324
+ if __name__ == "__main__":
325
+ app = Ros2TUI()
326
+ app.run()
@@ -0,0 +1,126 @@
1
+ # Changelog
2
+
3
+ ## v2.0 - Complete Rewrite (Current)
4
+
5
+ ### ✅ Completed
6
+
7
+ **Architecture Refactor:**
8
+ - Replaced subprocess-based CLI calls with native `rclpy` + `ros2*.api` integration
9
+ - Background ROS2 executor thread for non-blocking updates
10
+ - Clean entity abstraction layer supporting all ROS2 entity types
11
+ - Event-driven architecture with Textual messages
12
+
13
+ **Tree-Based Layout (like old ros2_explorer.py):**
14
+ - **Nodes view**: Expandable tree showing Publishers/Subscribers/Services/Actions under each node
15
+ - **Topics view**: Expandable tree showing Publishers/Subscribers under each topic
16
+ - **Services/Actions/Types views**: Flat list with namespace grouping
17
+ - Right panel shows detailed info for selected items
18
+
19
+ **Navigation:**
20
+ - Clickable links between entities (node → topic → message type)
21
+ - Browser-like history navigation (`b`/`f` keys for back/forward)
22
+ - 20-item history buffer
23
+
24
+ **Search:**
25
+ - Search bar with case-insensitive toggle (`i` button)
26
+ - Regex mode toggle (`re` button)
27
+ - Live filtering of tree lists
28
+
29
+ **View Switching:**
30
+ - `1` - Nodes view
31
+ - `2` - Topics view
32
+ - `3` - Services view
33
+ - `4` - Actions view
34
+ - `5` - Message Types view
35
+
36
+ **Auto-Refresh:**
37
+ - Entity lists refresh every 2 seconds (configurable via `--refresh-rate`)
38
+ - Burst refresh at startup (0s, 0.5s, 1.0s) for fast topic discovery
39
+ - Info panels update every 5 seconds
40
+ - Manual reload with `r` key
41
+
42
+ **Topic Watching:**
43
+ - `w` key to toggle watching a topic
44
+ - Watched topics show spinning propeller indicator (speed matches publishing rate)
45
+ - Propeller animation matches ros2 topic hz algorithm (1/mean(intervals), 10000 window)
46
+ - Watch mode automatically shows Hz statistics
47
+ - Watched topics use persistent subscriptions (reused for echo/hz/sample)
48
+ - Auto-watch topics from loaded profile when discovered
49
+
50
+ **Profile System:**
51
+ - Auto-save current state (watched topics + view) to default profile
52
+ - `p` key to save profile with custom name
53
+ - `--profile <name>` to load specific profile on startup
54
+ - `--continue` to resume last used profile
55
+ - Profiles stored in `~/.config/ros2_term/`
56
+ - Profile list shown in `--help` output
57
+
58
+ **Topic Data Inspection:**
59
+ - `h` key - Show Hz (publishing rate) for selected topic
60
+ - `e` key - Echo (continuous) messages from topic
61
+ - `s` key - Sample single message from topic
62
+ - `a` key - Toggle array visibility in echo/sample output
63
+ - Watched topics show Hz stats automatically
64
+
65
+ **UI Improvements:**
66
+ - In-place tree updates eliminate cursor flicker during refresh
67
+ - Tree expansion state preserved when switching views
68
+ - Path-based cursor tracking prevents jumping to wrong nodes
69
+ - `c` key - Collapse all top-level tree nodes
70
+ - `v` key - Toggle verbose mode (show/hide default ROS2 topics/services)
71
+ - Smooth propeller animation (0.1s update interval)
72
+
73
+ ### 🚧 Pending Features
74
+
75
+ From original notes:
76
+ - Topic search in Node view with highlighting (Task #1)
77
+ - Collapsible services/actions sections with persistent state (Task #4)
78
+ - Topic echo screen with field selection (Task #2)
79
+
80
+ ### 📁 File Structure
81
+
82
+ **New Files:**
83
+ - `app.py` - Main application (replaces `ros2_explorer.py`)
84
+ - `entity.py` - ROS2 entity types and info classes
85
+ - `ros2_client.py` - Native ROS2 client using rclpy
86
+ - `topic_watcher.py` - Topic watching system with Hz tracking and propeller animation
87
+ - `config.py` - Profile management (save/load watched topics and views)
88
+ - `widgets.py` - Reusable UI widgets (NodeTreePanel, TopicTreePanel, etc.)
89
+ - `messages.py` - Custom Textual messages for events
90
+ - `history.py` - Navigation history implementation
91
+ - `search_bar.py` - Search bar widget with toggles
92
+ - `profile_screen.py` - Modal screen for saving profiles
93
+
94
+ **Legacy Files (can be deleted):**
95
+ - `ros2_explorer.py` - Original implementation
96
+ - `multi_screen.py` - Multi-screen example
97
+ - `node_test.py` - rclpy test
98
+ - `screens.py` - Legacy screen definitions (no longer used)
99
+
100
+ **Config:**
101
+ - `app.css` - Textual CSS styling
102
+
103
+ ### 🎯 Key Improvements
104
+
105
+ 1. **Performance**: Native rclpy APIs are much faster than subprocess calls
106
+ 2. **Flexibility**: Supports all ROS2 entity types, not just nodes
107
+ 3. **Navigation**: Clickable links and history make exploration intuitive
108
+ 4. **Filtering**: Real-time search with regex support
109
+ 5. **Architecture**: Clean separation of concerns, reusable components
110
+ 6. **Topic Monitoring**: Visual propeller indicators show topic activity at a glance
111
+ 7. **State Persistence**: Profiles preserve your workflow across sessions
112
+ 8. **Smooth UI**: In-place tree updates eliminate flicker and cursor jumping
113
+
114
+ ### 🐛 Known Issues
115
+
116
+ None currently
117
+
118
+ ### 📝 Notes
119
+
120
+ The layout now matches the old `ros2_explorer.py` style where:
121
+ - Nodes expand to show their publishers/subscribers/services/actions
122
+ - Topics expand to show their publishers/subscribers
123
+ - Selecting a topic/service in the tree shows its details in the right panel
124
+ - Selecting a node name under a topic shows that node's details
125
+
126
+ This combines the best of both worlds: the familiar tree interface with modern architecture and full ROS2 support.
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: ros2-term
3
+ Version: 0.1.0
4
+ Summary: A terminal UI for inspecting ROS2 topics and nodes
5
+ Author-email: Spencer Diehl <docdiehl@gmail.com>
6
+ License: Apache-2.0
7
+ Keywords: robotics,ros2,terminal,tui
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Scientific/Engineering
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: numpy<2.0
20
+ Requires-Dist: pyyaml>=6.0.3
21
+ Requires-Dist: textual>=8.2.3
22
+ Provides-Extra: dev
23
+ Requires-Dist: black; extra == 'dev'
24
+ Requires-Dist: isort; extra == 'dev'
25
+ Requires-Dist: mypy; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # ROS2 Explorer TUI
29
+
30
+ A Terminal User Interface (TUI) app for exploring ROS2 nodes, topics, services, actions, and message types.
31
+
32
+ ## Requirements
33
+
34
+ - ROS2 Humble, Iron, or Jazzy already installed on system
35
+ - pipx (`sudo apt install pipx`)
36
+
37
+ ## Installation
38
+
39
+ Source your ROS2 installation first, then install with pipx:
40
+
41
+ source /opt/ros/humble/setup.bash # or iron, jazzy, etc.
42
+ pipx install ros2-term
43
+
44
+ Then run:
45
+
46
+ ros2-term
47
+
48
+ **Note:** ROS2 must be sourced in any shell where you run `ros2-term`.
49
+
50
+ ### Command Line Options
51
+
52
+ ros2-term [--profile NAME] [--continue] [--refresh-rate SECONDS]
53
+
54
+ - `--profile NAME` - Load specific profile on startup
55
+ - `--continue` - Resume last used profile
56
+ - `--refresh-rate SECONDS` - Set refresh interval (default: 2.0)
57
+ - `--help` - Show available profiles and config location
58
+
59
+
60
+ ## Troubleshooting
61
+
62
+ If you have multiple Python versions installed and see import errors related
63
+ to ROS2 packages, force pipx to use the system Python explicitly:
64
+
65
+ pipx install ros2-term --python $(which python3)
66
+
67
+ ## Features
68
+
69
+ - Tree views for nodes/topics with expandable details
70
+ - **Topic watching** (`w`) with spinning propeller showing publish rate
71
+ - **Topic inspection**: Hz (`h`), echo (`e`), sample (`s`)
72
+ - **Profile system**: Auto-save/load watched topics and views
73
+ - Clickable links between entities (node → topic → type)
74
+ - History navigation (`b`/`f`)
75
+ - Search with regex support
76
+ - Native `rclpy` integration (no subprocess overhead)
77
+
78
+ ## Key Components
79
+
80
+ - `ros2_client.py` - Native `rclpy` integration with background executor
81
+ - `topic_watcher.py` - Hz tracking and propeller animation
82
+ - `config.py` - Profile persistence
83
+ - `widgets.py` - In-place tree updates (no flicker)
84
+
85
+ ### Keyboard Shortcuts
86
+
87
+ | Key | Action | Visible in Footer |
88
+ |-----|--------|-------------------|
89
+ | `?` | Show help screen | ✓ |
90
+ | `w` | Watch/unwatch topic | ✓ |
91
+ | `h` | Show Hz for topic | ✓ |
92
+ | `e` | Echo topic (continuous) | ✓ |
93
+ | `s` | Sample topic (single msg) | ✓ |
94
+ | `a` | Toggle array visibility | ✓ |
95
+ | `r` | Reload current view | ✓ |
96
+ | `b` | Navigate back in history | |
97
+ | `f` | Navigate forward in history | |
98
+ | `v` | Toggle verbose mode | |
99
+ | `c` | Collapse all tree nodes | |
100
+ | `p` | Save profile | |
101
+ | `1` | View nodes | |
102
+ | `2` | View topics | |
103
+ | `3` | View services | |
104
+ | `4` | View actions | |
105
+ | `5` | View message types | |
106
+ | `q` | Quit | |
107
+
108
+ ### Navigation
109
+
110
+ - Arrow keys navigate tree, Enter expands/collapses
111
+ - Click entity names in info panel to navigate
112
+ - `b`/`f` for history navigation
113
+ - Tree state preserved when switching views