@jiawang1209/codex-hud 0.1.0

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 (56) hide show
  1. package/.codex-plugin/plugin.json +41 -0
  2. package/CHANGELOG.md +9 -0
  3. package/LICENSE +21 -0
  4. package/README.md +342 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +206 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/codex-statusline.d.ts +9 -0
  9. package/dist/codex-statusline.js +96 -0
  10. package/dist/codex-statusline.js.map +1 -0
  11. package/dist/config.d.ts +62 -0
  12. package/dist/config.js +199 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +5 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/installer.d.ts +17 -0
  18. package/dist/installer.js +125 -0
  19. package/dist/installer.js.map +1 -0
  20. package/dist/native-runner.d.ts +28 -0
  21. package/dist/native-runner.js +122 -0
  22. package/dist/native-runner.js.map +1 -0
  23. package/dist/render.d.ts +11 -0
  24. package/dist/render.js +149 -0
  25. package/dist/render.js.map +1 -0
  26. package/dist/snapshot.d.ts +12 -0
  27. package/dist/snapshot.js +32 -0
  28. package/dist/snapshot.js.map +1 -0
  29. package/dist/sources/codex.d.ts +50 -0
  30. package/dist/sources/codex.js +182 -0
  31. package/dist/sources/codex.js.map +1 -0
  32. package/dist/sources/git.d.ts +2 -0
  33. package/dist/sources/git.js +37 -0
  34. package/dist/sources/git.js.map +1 -0
  35. package/dist/sources/session.d.ts +14 -0
  36. package/dist/sources/session.js +278 -0
  37. package/dist/sources/session.js.map +1 -0
  38. package/dist/tmux-runner.d.ts +26 -0
  39. package/dist/tmux-runner.js +203 -0
  40. package/dist/tmux-runner.js.map +1 -0
  41. package/dist/types.d.ts +42 -0
  42. package/dist/types.js +2 -0
  43. package/dist/types.js.map +1 -0
  44. package/docs/installation.md +42 -0
  45. package/docs/native-codex-cli-patch.md +55 -0
  46. package/docs/plugin-marketplace.md +42 -0
  47. package/docs/release.md +35 -0
  48. package/docs/superpowers/plans/2026-05-23-codex-hud-mvp.md +234 -0
  49. package/docs/superpowers/plans/2026-05-23-productized-native-bundle.md +431 -0
  50. package/docs/superpowers/specs/2026-05-23-codex-hud-design.md +197 -0
  51. package/docs/upstream/codex-command-backed-statusline.md +101 -0
  52. package/package.json +49 -0
  53. package/patches/codex-cli-command-statusline.patch +459 -0
  54. package/plugins/codex-hud/.codex-plugin/plugin.json +41 -0
  55. package/plugins/codex-hud/skills/codex-hud/SKILL.md +153 -0
  56. package/skills/codex-hud/SKILL.md +153 -0
@@ -0,0 +1,101 @@
1
+ # Feature Request: Command-Backed TUI Status Line for Codex CLI
2
+
3
+ ## Summary
4
+
5
+ Please add a supported command-backed status line provider to Codex CLI, similar to Claude Code's `statusLine.command`.
6
+
7
+ Codex CLI already supports a useful fixed-item footer through `[tui].status_line`, with items such as `model-with-reasoning`, `current-dir`, `git-branch`, `context-used`, `five-hour-limit`, `weekly-limit`, and `fast-mode`. That works well for built-in telemetry, but it does not let plugins render custom multi-line HUDs, custom labels, ANSI styling, or derived session signals.
8
+
9
+ ## Proposed Configuration
10
+
11
+ One possible TOML shape:
12
+
13
+ ```toml
14
+ [tui.status_line]
15
+ type = "command"
16
+ command = "codex-hud status --statusline"
17
+ refresh_ms = 1000
18
+ ```
19
+
20
+ Alternatively, Codex could keep the current array form for built-in items and add a separate command provider:
21
+
22
+ ```toml
23
+ [tui.status_line_command]
24
+ command = "codex-hud status --statusline"
25
+ refresh_ms = 1000
26
+ ```
27
+
28
+ ## Input Contract
29
+
30
+ Codex would invoke the configured command with session/status JSON on stdin. A minimal payload could include:
31
+
32
+ ```json
33
+ {
34
+ "model": "gpt-5.5",
35
+ "reasoning_effort": "medium",
36
+ "cwd": "/path/to/project",
37
+ "git_branch": "main",
38
+ "git_dirty": true,
39
+ "context_used_percent": 45,
40
+ "rate_limits": {
41
+ "primary": {
42
+ "used_percent": 25,
43
+ "window_minutes": 300
44
+ },
45
+ "secondary": {
46
+ "used_percent": 40,
47
+ "window_minutes": 10080
48
+ }
49
+ },
50
+ "task_progress": {
51
+ "completed": 2,
52
+ "total": 5,
53
+ "current": "Implement wrapper"
54
+ },
55
+ "active_tools": [
56
+ { "name": "exec", "status": "active" }
57
+ ]
58
+ }
59
+ ```
60
+
61
+ ## Output Contract
62
+
63
+ The command prints one or more lines to stdout. Codex renders that output in the TUI status area.
64
+
65
+ Example output:
66
+
67
+ ```text
68
+ [gpt-5.5 medium] │ codex-hud git:(main*)
69
+ Context █████░░░░░ 45% │ Usage ██░░░░░░░░ 25% (1h 15m / 5h)
70
+ Todos 2/5 │ Exec active
71
+ ```
72
+
73
+ ## Why This Matters
74
+
75
+ Command-backed status lines would allow Codex plugins to provide high-signal, workflow-specific status displays without requiring users to fork Codex CLI or run external panes.
76
+
77
+ This would unlock:
78
+
79
+ - Plugin-rendered HUDs for context, usage, tool state, todos, git, and session health.
80
+ - Team-specific status banners and policy indicators.
81
+ - Compact terminal dashboards for users running multiple Codex sessions.
82
+ - Ecosystem parity with Claude Code statusline plugins while keeping Codex's built-in status line available for users who prefer it.
83
+
84
+ ## Real Use Case
85
+
86
+ `codex-hud` currently ships:
87
+
88
+ - A standalone `codex-hud status` renderer.
89
+ - A `codex-hud run` tmux wrapper that approximates a persistent HUD.
90
+ - A `codex-hud setup` command that configures the best available native `[tui].status_line` built-in items.
91
+
92
+ The missing piece is a native Codex TUI provider that can call `codex-hud status` and render its output directly in the main Codex interface.
93
+
94
+ ## Compatibility
95
+
96
+ This can be additive:
97
+
98
+ - Existing `[tui].status_line = [...]` behavior remains unchanged.
99
+ - Command-backed status lines are opt-in.
100
+ - Codex can impose timeout, max-line, and max-byte limits.
101
+ - Codex can strip unsupported control sequences while preserving common ANSI color codes if desired.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@jiawang1209/codex-hud",
3
+ "version": "0.1.0",
4
+ "description": "Real-time terminal HUD for Codex CLI and Agent CLI sessions.",
5
+ "type": "module",
6
+ "homepage": "https://github.com/Jiawang1209/codex-hud",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Jiawang1209/codex-hud.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/Jiawang1209/codex-hud/issues"
13
+ },
14
+ "bin": {
15
+ "codex-hud": "dist/index.js"
16
+ },
17
+ "files": [
18
+ "dist/",
19
+ ".codex-plugin/",
20
+ "plugins/",
21
+ "patches/",
22
+ "docs/",
23
+ "skills/",
24
+ "CHANGELOG.md",
25
+ "LICENSE",
26
+ "README.md"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "test": "npm run build && node --test",
31
+ "prepack": "npm run build",
32
+ "pack:check": "npm test && npm pack --dry-run"
33
+ },
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^20.19.25",
39
+ "typescript": "^5.9.3"
40
+ },
41
+ "keywords": [
42
+ "codex",
43
+ "codex-cli",
44
+ "hud",
45
+ "statusline",
46
+ "terminal"
47
+ ],
48
+ "license": "MIT"
49
+ }
@@ -0,0 +1,459 @@
1
+ diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
2
+ index e76b4c0..69b283c 100644
3
+ --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
4
+ +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
5
+ @@ -177,11 +177,12 @@ use super::footer::footer_hint_items_width;
6
+ use super::footer::footer_line_width;
7
+ use super::footer::inset_footer_hint_area;
8
+ use super::footer::max_left_width_for_right;
9
+ -use super::footer::passive_footer_status_line;
10
+ +use super::footer::passive_footer_status_lines;
11
+ use super::footer::render_context_right;
12
+ use super::footer::render_footer_from_props;
13
+ use super::footer::render_footer_hint_items;
14
+ use super::footer::render_footer_line;
15
+ +use super::footer::render_footer_lines;
16
+ use super::footer::reset_mode_after_activity;
17
+ use super::footer::side_conversation_context_line;
18
+ use super::footer::single_line_footer_layout;
19
+ @@ -4154,10 +4155,14 @@ impl ChatComposer {
20
+ }
21
+
22
+ pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) -> bool {
23
+ - if self.footer.status_line_value == status_line {
24
+ + self.set_status_lines(status_line.map(|line| vec![line]))
25
+ + }
26
+ +
27
+ + pub(crate) fn set_status_lines(&mut self, status_lines: Option<Vec<Line<'static>>>) -> bool {
28
+ + if self.footer.status_line_value == status_lines {
29
+ return false;
30
+ }
31
+ - self.footer.status_line_value = status_line;
32
+ + self.footer.status_line_value = status_lines;
33
+ true
34
+ }
35
+
36
+ @@ -4422,11 +4427,30 @@ impl ChatComposer {
37
+ let available_width =
38
+ hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize;
39
+ let status_line_active = uses_passive_footer_status_layout(&footer_props);
40
+ - let combined_status_line = if status_line_active {
41
+ - passive_footer_status_line(&footer_props)
42
+ + let combined_status_lines = if status_line_active {
43
+ + passive_footer_status_lines(&footer_props)
44
+ } else {
45
+ None
46
+ };
47
+ + let multi_line_status_lines = combined_status_lines
48
+ + .as_ref()
49
+ + .filter(|lines| lines.len() > 1)
50
+ + .map(|lines| {
51
+ + lines
52
+ + .iter()
53
+ + .cloned()
54
+ + .map(|line| {
55
+ + truncate_line_with_ellipsis_if_overflow(
56
+ + line,
57
+ + available_width,
58
+ + )
59
+ + })
60
+ + .collect::<Vec<_>>()
61
+ + });
62
+ + let combined_status_line = combined_status_lines
63
+ + .as_ref()
64
+ + .and_then(|lines| lines.first())
65
+ + .cloned();
66
+ let mut truncated_status_line = if status_line_active {
67
+ combined_status_line.as_ref().map(|line| {
68
+ truncate_line_with_ellipsis_if_overflow(line.clone(), available_width)
69
+ @@ -4467,6 +4491,8 @@ impl ChatComposer {
70
+ Some(side_conversation_context_line(label))
71
+ } else if let Some(line) = self.shell_mode_footer_line() {
72
+ Some(line)
73
+ + } else if multi_line_status_lines.is_some() {
74
+ + None
75
+ } else if status_line_active {
76
+ let full = self.mode_indicator_line(show_cycle_hint);
77
+ let compact = self.mode_indicator_line(/*show_cycle_hint*/ false);
78
+ @@ -4538,7 +4564,9 @@ impl ChatComposer {
79
+ match summary_left {
80
+ SummaryLeft::Default => {
81
+ if status_line_active {
82
+ - if let Some(line) = truncated_status_line.clone() {
83
+ + if let Some(lines) = multi_line_status_lines.clone() {
84
+ + render_footer_lines(hint_rect, buf, lines);
85
+ + } else if let Some(line) = truncated_status_line.clone() {
86
+ render_footer_line(hint_rect, buf, line);
87
+ } else {
88
+ render_footer_from_props(
89
+ @@ -4575,7 +4603,9 @@ impl ChatComposer {
90
+ } else if let Some(items) = active_footer_hint_override {
91
+ render_footer_hint_items(hint_rect, buf, items);
92
+ } else if status_line_active {
93
+ - if let Some(line) = truncated_status_line {
94
+ + if let Some(lines) = multi_line_status_lines.clone() {
95
+ + render_footer_lines(hint_rect, buf, lines);
96
+ + } else if let Some(line) = truncated_status_line {
97
+ render_footer_line(hint_rect, buf, line);
98
+ }
99
+ } else {
100
+ diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs
101
+ index 113385f..4d94a30 100644
102
+ --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs
103
+ +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs
104
+ @@ -25,7 +25,7 @@ pub(super) struct FooterState {
105
+ pub(super) collaboration_mode_indicator: Option<CollaborationModeIndicator>,
106
+ pub(super) goal_status_indicator: Option<GoalStatusIndicator>,
107
+ pub(super) ide_context_active: bool,
108
+ - pub(super) status_line_value: Option<Line<'static>>,
109
+ + pub(super) status_line_value: Option<Vec<Line<'static>>>,
110
+ pub(super) status_line_hyperlink_url: Option<String>,
111
+ pub(super) status_line_enabled: bool,
112
+ pub(super) side_conversation_context_label: Option<String>,
113
+ @@ -63,11 +63,17 @@ impl FooterState {
114
+
115
+ #[cfg(test)]
116
+ pub(super) fn status_line_text(&self) -> Option<String> {
117
+ - self.status_line_value.as_ref().map(|line| {
118
+ - line.spans
119
+ + self.status_line_value.as_ref().map(|lines| {
120
+ + lines
121
+ .iter()
122
+ - .map(|span| span.content.as_ref())
123
+ - .collect::<String>()
124
+ + .map(|line| {
125
+ + line.spans
126
+ + .iter()
127
+ + .map(|span| span.content.as_ref())
128
+ + .collect::<String>()
129
+ + })
130
+ + .collect::<Vec<_>>()
131
+ + .join("\n")
132
+ })
133
+ }
134
+ }
135
+ diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs
136
+ index 0b6aabf..7834fcf 100644
137
+ --- a/codex-rs/tui/src/bottom_pane/footer.rs
138
+ +++ b/codex-rs/tui/src/bottom_pane/footer.rs
139
+ @@ -74,7 +74,7 @@ pub(crate) struct FooterProps {
140
+ ///
141
+ /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`.
142
+ pub(crate) quit_shortcut_key: KeyBinding,
143
+ - pub(crate) status_line_value: Option<Line<'static>>,
144
+ + pub(crate) status_line_value: Option<Vec<Line<'static>>>,
145
+ pub(crate) status_line_enabled: bool,
146
+ pub(crate) key_hints: FooterKeyHints,
147
+ /// Active thread label shown when the footer is rendering contextual information instead of an
148
+ @@ -261,6 +261,16 @@ pub(crate) fn render_footer_line(area: Rect, buf: &mut Buffer, line: Line<'stati
149
+ .render(area, buf);
150
+ }
151
+
152
+ +/// Render multiple precomputed footer lines.
153
+ +pub(crate) fn render_footer_lines(area: Rect, buf: &mut Buffer, lines: Vec<Line<'static>>) {
154
+ + Paragraph::new(prefix_lines(
155
+ + lines,
156
+ + " ".repeat(FOOTER_INDENT_COLS).into(),
157
+ + " ".repeat(FOOTER_INDENT_COLS).into(),
158
+ + ))
159
+ + .render(area, buf);
160
+ +}
161
+ +
162
+ /// Render footer content directly from `FooterProps`.
163
+ ///
164
+ /// This is intentionally not part of the width-based collapse/fallback logic.
165
+ @@ -711,8 +721,8 @@ fn footer_from_props_lines(
166
+ let key_hints = props.key_hints;
167
+ // Passive footer context can come from the configurable status line, the
168
+ // active agent label, or both combined.
169
+ - if let Some(status_line) = passive_footer_status_line(props) {
170
+ - return vec![status_line];
171
+ + if let Some(status_lines) = passive_footer_status_lines(props) {
172
+ + return status_lines;
173
+ }
174
+ match props.mode {
175
+ FooterMode::QuitShortcutReminder => {
176
+ @@ -770,27 +780,27 @@ fn footer_from_props_lines(
177
+ /// The returned line may contain the configured status line, the currently viewed agent label, or
178
+ /// both combined. Active instructional states such as quit reminders, shortcut overlays, and queue
179
+ /// prompts deliberately return `None` so those call-to-action hints stay visible.
180
+ -pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<Line<'static>> {
181
+ +pub(crate) fn passive_footer_status_lines(props: &FooterProps) -> Option<Vec<Line<'static>>> {
182
+ if !shows_passive_footer_line(props) {
183
+ return None;
184
+ }
185
+
186
+ - let mut line = if props.status_line_enabled {
187
+ + let mut lines = if props.status_line_enabled {
188
+ props.status_line_value.clone()
189
+ } else {
190
+ None
191
+ };
192
+
193
+ if let Some(active_agent_label) = props.active_agent_label.as_ref() {
194
+ - if let Some(existing) = line.as_mut() {
195
+ + if let Some(existing) = lines.as_mut().and_then(|lines| lines.last_mut()) {
196
+ existing.spans.push(" · ".dim());
197
+ existing.spans.push(active_agent_label.clone().dim());
198
+ } else {
199
+ - line = Some(Line::from(active_agent_label.clone()).dim());
200
+ + lines = Some(vec![Line::from(active_agent_label.clone()).dim()]);
201
+ }
202
+ }
203
+
204
+ - line
205
+ + lines
206
+ }
207
+
208
+ /// Whether the current footer mode allows contextual information to replace instructional hints.
209
+ @@ -1314,7 +1324,7 @@ mod tests {
210
+ };
211
+ let status_line_active = uses_passive_footer_status_layout(props);
212
+ let passive_status_line = if status_line_active {
213
+ - passive_footer_status_line(props)
214
+ + passive_footer_status_lines(props).and_then(|lines| lines.into_iter().next())
215
+ } else {
216
+ None
217
+ };
218
+ @@ -1765,7 +1775,7 @@ mod tests {
219
+ collaboration_modes_enabled: false,
220
+ is_wsl: false,
221
+ quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
222
+ - status_line_value: Some(Line::from("Status line content".to_string())),
223
+ + status_line_value: Some(vec![Line::from("Status line content".to_string())]),
224
+ status_line_enabled: true,
225
+ key_hints: FooterKeyHints::default_bindings(),
226
+ active_agent_label: None,
227
+ @@ -1781,7 +1791,7 @@ mod tests {
228
+ collaboration_modes_enabled: false,
229
+ is_wsl: false,
230
+ quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
231
+ - status_line_value: Some(Line::from("Status line content".to_string())),
232
+ + status_line_value: Some(vec![Line::from("Status line content".to_string())]),
233
+ status_line_enabled: true,
234
+ key_hints: FooterKeyHints::default_bindings(),
235
+ active_agent_label: None,
236
+ @@ -1797,7 +1807,7 @@ mod tests {
237
+ collaboration_modes_enabled: false,
238
+ is_wsl: false,
239
+ quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
240
+ - status_line_value: Some(Line::from("Status line content".to_string())),
241
+ + status_line_value: Some(vec![Line::from("Status line content".to_string())]),
242
+ status_line_enabled: true,
243
+ key_hints: FooterKeyHints::default_bindings(),
244
+ active_agent_label: None,
245
+ @@ -1888,9 +1898,9 @@ mod tests {
246
+ collaboration_modes_enabled: true,
247
+ is_wsl: false,
248
+ quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
249
+ - status_line_value: Some(Line::from(
250
+ + status_line_value: Some(vec![Line::from(
251
+ "Status line content that should truncate before the mode indicator".to_string(),
252
+ - )),
253
+ + )]),
254
+ status_line_enabled: true,
255
+ key_hints: FooterKeyHints::default_bindings(),
256
+ active_agent_label: None,
257
+ @@ -1928,7 +1938,7 @@ mod tests {
258
+ collaboration_modes_enabled: false,
259
+ is_wsl: false,
260
+ quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
261
+ - status_line_value: Some(Line::from("Status line content".to_string())),
262
+ + status_line_value: Some(vec![Line::from("Status line content".to_string())]),
263
+ status_line_enabled: true,
264
+ key_hints: FooterKeyHints::default_bindings(),
265
+ active_agent_label: Some("Robie [explorer]".to_string()),
266
+ @@ -1947,10 +1957,10 @@ mod tests {
267
+ collaboration_modes_enabled: true,
268
+ is_wsl: false,
269
+ quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
270
+ - status_line_value: Some(Line::from(
271
+ + status_line_value: Some(vec![Line::from(
272
+ "Status line content that is definitely too long to fit alongside the mode label"
273
+ .to_string(),
274
+ - )),
275
+ + )]),
276
+ status_line_enabled: true,
277
+ key_hints: FooterKeyHints::default_bindings(),
278
+ active_agent_label: None,
279
+ diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
280
+ index 165169c..a45eb3c 100644
281
+ --- a/codex-rs/tui/src/bottom_pane/mod.rs
282
+ +++ b/codex-rs/tui/src/bottom_pane/mod.rs
283
+ @@ -1656,6 +1656,12 @@ impl BottomPane {
284
+ }
285
+ }
286
+
287
+ + pub(crate) fn set_status_lines(&mut self, status_lines: Option<Vec<Line<'static>>>) {
288
+ + if self.composer.set_status_lines(status_lines) {
289
+ + self.request_redraw();
290
+ + }
291
+ + }
292
+ +
293
+ pub(crate) fn set_status_line_hyperlink(&mut self, url: Option<String>) {
294
+ if self.composer.set_status_line_hyperlink(url) {
295
+ self.request_redraw();
296
+ diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs
297
+ index 70f213b..2c92454 100644
298
+ --- a/codex-rs/tui/src/chatwidget/status_controls.rs
299
+ +++ b/codex-rs/tui/src/chatwidget/status_controls.rs
300
+ @@ -69,6 +69,11 @@ impl ChatWidget {
301
+ self.bottom_pane.set_status_line(status_line);
302
+ }
303
+
304
+ + /// Sets the currently rendered footer status-line value as one or more lines.
305
+ + pub(crate) fn set_status_lines(&mut self, status_lines: Option<Vec<Line<'static>>>) {
306
+ + self.bottom_pane.set_status_lines(status_lines);
307
+ + }
308
+ +
309
+ /// Sets the terminal hyperlink target for the currently rendered footer status line.
310
+ pub(crate) fn set_status_line_hyperlink(&mut self, url: Option<String>) {
311
+ self.bottom_pane.set_status_line_hyperlink(url);
312
+ diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs
313
+ index e34fe46..a369367 100644
314
+ --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs
315
+ +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs
316
+ @@ -12,7 +12,9 @@ use codex_app_server_protocol::AskForApproval;
317
+ use codex_protocol::config_types::ApprovalsReviewer;
318
+ use codex_protocol::config_types::ServiceTier;
319
+ use codex_protocol::models::PermissionProfile;
320
+ +use codex_ansi_escape::ansi_escape_line;
321
+ use codex_utils_sandbox_summary::summarize_permission_profile;
322
+ +use std::process::Command;
323
+
324
+ use super::status_state::TerminalTitleStatusKind;
325
+
326
+ @@ -43,6 +45,7 @@ const TERMINAL_TITLE_ACTION_REQUIRED_PREFIX_HIDDEN: &str = "[ . ] Action Require
327
+ /// from the same selection set.
328
+ struct StatusSurfaceSelections {
329
+ status_line_items: Vec<StatusLineItem>,
330
+ + status_line_command: Option<String>,
331
+ invalid_status_line_items: Vec<String>,
332
+ terminal_title_items: Vec<TerminalTitleItem>,
333
+ invalid_terminal_title_items: Vec<String>,
334
+ @@ -81,8 +84,11 @@ impl ChatWidget {
335
+ let (status_line_items, invalid_status_line_items) = self.status_line_items_with_invalids();
336
+ let (terminal_title_items, invalid_terminal_title_items) =
337
+ self.terminal_title_items_with_invalids();
338
+ + let status_line_command =
339
+ + status_line_command_from_ids(self.configured_status_line_items());
340
+ StatusSurfaceSelections {
341
+ status_line_items,
342
+ + status_line_command,
343
+ invalid_status_line_items,
344
+ terminal_title_items,
345
+ invalid_terminal_title_items,
346
+ @@ -158,6 +164,14 @@ impl ChatWidget {
347
+ }
348
+
349
+ fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) {
350
+ + if let Some(command) = selections.status_line_command.as_ref() {
351
+ + self.bottom_pane.set_status_line_enabled(true);
352
+ + let lines = status_line_command_output(command, self.status_line_cwd());
353
+ + self.set_status_lines(lines);
354
+ + self.set_status_line_hyperlink(/*url*/ None);
355
+ + return;
356
+ + }
357
+ +
358
+ let enabled = !selections.status_line_items.is_empty();
359
+ self.bottom_pane.set_status_line_enabled(enabled);
360
+ if !enabled {
361
+ @@ -384,7 +398,11 @@ impl ChatWidget {
362
+ ///
363
+ /// Unknown ids are deduplicated in insertion order for warning messages.
364
+ fn status_line_items_with_invalids(&self) -> (Vec<StatusLineItem>, Vec<String>) {
365
+ - parse_items_with_invalids(self.configured_status_line_items())
366
+ + parse_items_with_invalids(
367
+ + self.configured_status_line_items()
368
+ + .into_iter()
369
+ + .filter(|item| !is_status_line_command_item(item)),
370
+ + )
371
+ }
372
+
373
+ pub(super) fn configured_status_line_items(&self) -> Vec<String> {
374
+ @@ -952,3 +970,85 @@ where
375
+ }
376
+ (items, invalid)
377
+ }
378
+ +
379
+ +const STATUS_LINE_COMMAND_PREFIX: &str = "command:";
380
+ +
381
+ +fn is_status_line_command_item(item: &str) -> bool {
382
+ + item.trim_start().starts_with(STATUS_LINE_COMMAND_PREFIX)
383
+ +}
384
+ +
385
+ +fn status_line_command_from_ids(ids: impl IntoIterator<Item = String>) -> Option<String> {
386
+ + ids.into_iter().find_map(|item| {
387
+ + let command = item.trim_start().strip_prefix(STATUS_LINE_COMMAND_PREFIX)?;
388
+ + let command = command.trim();
389
+ + (!command.is_empty()).then(|| command.to_string())
390
+ + })
391
+ +}
392
+ +
393
+ +fn status_line_command_output(command: &str, cwd: &Path) -> Option<Vec<Line<'static>>> {
394
+ + let output = Command::new("sh")
395
+ + .arg("-lc")
396
+ + .arg(command)
397
+ + .current_dir(cwd)
398
+ + .output()
399
+ + .ok()?;
400
+ +
401
+ + if !output.status.success() {
402
+ + return None;
403
+ + }
404
+ +
405
+ + let text = String::from_utf8_lossy(&output.stdout);
406
+ + let lines = text
407
+ + .lines()
408
+ + .map(str::trim_end)
409
+ + .filter(|line| !line.is_empty())
410
+ + .take(3)
411
+ + .map(ansi_escape_line)
412
+ + .collect::<Vec<_>>();
413
+ + (!lines.is_empty()).then_some(lines)
414
+ +}
415
+ +
416
+ +#[cfg(test)]
417
+ +mod command_status_line_tests {
418
+ + use super::*;
419
+ + use ratatui::style::Color;
420
+ + use std::path::Path;
421
+ +
422
+ + #[test]
423
+ + fn status_line_command_from_ids_extracts_command_item() {
424
+ + let command = status_line_command_from_ids([
425
+ + "model-with-reasoning".to_string(),
426
+ + "command: codex-hud status".to_string(),
427
+ + ]);
428
+ +
429
+ + assert_eq!(command, Some("codex-hud status".to_string()));
430
+ + }
431
+ +
432
+ + #[test]
433
+ + fn parse_items_with_invalids_ignores_command_items() {
434
+ + let (items, invalid) = parse_items_with_invalids::<StatusLineItem>(
435
+ + ["command: codex-hud status".to_string()]
436
+ + .into_iter()
437
+ + .filter(|item| !is_status_line_command_item(item)),
438
+ + );
439
+ +
440
+ + assert!(items.is_empty());
441
+ + assert!(invalid.is_empty());
442
+ + }
443
+ +
444
+ + #[test]
445
+ + fn status_line_command_output_preserves_multiline_stdout_for_footer() {
446
+ + let output = status_line_command_output("printf 'one\\ntwo\\n'", Path::new("."));
447
+ +
448
+ + assert_eq!(output, Some(vec![Line::from("one"), Line::from("two")]));
449
+ + }
450
+ +
451
+ + #[test]
452
+ + fn status_line_command_output_parses_ansi_styles() {
453
+ + let output = status_line_command_output("printf '\\033[35mHUD\\033[0m\\n'", Path::new("."))
454
+ + .expect("status line output");
455
+ +
456
+ + assert_eq!(output[0].spans[0].content.as_ref(), "HUD");
457
+ + assert_eq!(output[0].spans[0].style.fg, Some(Color::Magenta));
458
+ + }
459
+ +}
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "codex-hud",
3
+ "version": "0.1.0",
4
+ "description": "Real-time terminal HUD for Codex CLI sessions: context, tools, todos, git status, and session activity at a glance.",
5
+ "author": {
6
+ "name": "Codex HUD contributors",
7
+ "url": "https://github.com/Jiawang1209/codex-hud"
8
+ },
9
+ "homepage": "https://github.com/Jiawang1209/codex-hud",
10
+ "repository": "https://github.com/Jiawang1209/codex-hud",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "codex",
14
+ "codex-cli",
15
+ "hud",
16
+ "statusline",
17
+ "terminal",
18
+ "agent-cli"
19
+ ],
20
+ "skills": "./skills/",
21
+ "interface": {
22
+ "displayName": "Codex HUD",
23
+ "shortDescription": "Terminal HUD for Codex CLI sessions",
24
+ "longDescription": "Codex HUD provides a Codex CLI-first terminal telemetry layer for model, reasoning effort, project, git, context, usage, tools, todos, and session signals. The first version ships as a standalone CLI with a Codex plugin wrapper for setup and future native statusline integration.",
25
+ "developerName": "Codex HUD contributors",
26
+ "category": "Engineering",
27
+ "capabilities": [
28
+ "Read"
29
+ ],
30
+ "websiteURL": "https://github.com/Jiawang1209/codex-hud",
31
+ "privacyPolicyURL": "https://github.com/Jiawang1209/codex-hud#privacy",
32
+ "termsOfServiceURL": "https://github.com/Jiawang1209/codex-hud#license",
33
+ "brandColor": "#10A37F",
34
+ "defaultPrompt": [
35
+ "Use Codex HUD to inspect my current Codex CLI environment",
36
+ "Show me how to run codex-hud status",
37
+ "Diagnose my codex-hud setup"
38
+ ],
39
+ "screenshots": []
40
+ }
41
+ }