opendocker 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.
package/AGENTS.md ADDED
@@ -0,0 +1,22 @@
1
+ # Agent Development Guide
2
+
3
+ ## Commands
4
+ - **Run**: `bun run src/main.tsx`
5
+ - **Dev**: `bun --watch run src/main.tsx`
6
+ - **Test**: `bun test` (uses `bun:test` imports)
7
+ - **Install**: `bun install`
8
+
9
+ ## Code Style
10
+ - **Framework**: React with @opentui/react for terminal UI
11
+ - **Imports**: Use path aliases `@/*` for src/ and `@tui/*` for src/cli/cmd/tui/
12
+ - **Components**: Default exports, PascalCase naming
13
+ - **Keyboard**: Vim-style navigation (j/k for up/down, q to quit)
14
+ - **Colors**: Use centralized colors from `src/utils/colors.ts`
15
+ - **Error Handling**: Try-catch with state-based error handling
16
+ - **Shell Commands**: Use `Bun.$` for shell execution
17
+
18
+ ## Key Patterns
19
+ - Use `useKeyboard()` hook for terminal input handling
20
+ - Components use flexbox layout with terminal dimensions
21
+ - State management with React hooks (useState, useEffect)
22
+ - Terminal UI elements: `<box>`, `<text>` with styling props
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ <p align="center">The AI coding agent built for the terminal.</p>
2
+
3
+ ![OpenDocker Terminal UI](src/assets/images/banner.jpg)
4
+
5
+ ## Usage
6
+
7
+ To install dependencies:
8
+
9
+ ```bash
10
+ bun install
11
+ ```
12
+
13
+ To run:
14
+
15
+ ```bash
16
+ bun start
17
+ ```
18
+
19
+ ## FAQ
20
+
21
+ ### How is this different than Claude Code?
22
+
23
+ It's very similar to lazydocker in terms of capability. Here are the key differences:
24
+
25
+ - It looks good
26
+ - It's easier on the eyes
27
+ - It looks good
package/TODO.md ADDED
@@ -0,0 +1,53 @@
1
+ # OpenDocker TODO List
2
+
3
+ ## Code Quality Improvements
4
+
5
+ ### High Priority
6
+ - [ ] Fix inconsistent component naming (`VolumesPanes.tsx` → `VolumesPane.tsx`)
7
+ - [ ] Add comprehensive error handling and error boundaries
8
+ - [ ] Implement proper null safety checks (especially for `activeContainer`)
9
+ - [ ] Add TypeScript interfaces for Docker API responses
10
+ - [ ] Standardize state management across all components
11
+
12
+ ### Medium Priority
13
+ - [ ] Add loading states to Images and Volumes panes
14
+ - [ ] Implement proper cleanup for processes and event listeners
15
+ - [ ] Add prop type documentation to components
16
+ - [ ] Standardize error message formatting
17
+
18
+ ## Performance & Architecture
19
+
20
+ ### High Priority
21
+ - [ ] Create centralized Docker service layer (abstract CLI calls)
22
+ - [ ] Implement debouncing/throttling for Docker commands
23
+ - [ ] Add process cleanup to prevent memory leaks
24
+ - [ ] Implement data caching mechanism
25
+
26
+ ### Medium Priority
27
+ - [ ] Separate data fetching logic from UI components
28
+ - [ ] Add configuration management (environment variables)
29
+ - [ ] Implement proper logging system
30
+
31
+ ## Testing & Documentation
32
+
33
+ ## Features & UX
34
+
35
+ ### High Priority
36
+ - [ ] Implement filter logs input
37
+ - [ ] Add real-time updates with proper polling mechanism
38
+ - [ ] Create help screen with keyboard shortcuts
39
+ - [ ] Add visual feedback for user actions
40
+
41
+ ### Medium Priority
42
+ - [ ] Implement container management actions (start/stop/remove)
43
+ - [ ] Add confirmation dialogs for destructive operations
44
+ - [ ] Create settings/configuration screen
45
+ - [ ] Add export/import functionality for logs
46
+
47
+ ## Infrastructure
48
+
49
+ ### Medium Priority
50
+ - [ ] Add Docker health checks and connection validation
51
+ - [ ] Implement proper error recovery mechanisms
52
+ - [ ] Add performance monitoring
53
+ - [ ] Create build/development optimization
@@ -0,0 +1,24 @@
1
+ version: '3.8'
2
+
3
+ services:
4
+ logger1:
5
+ image: alpine:latest
6
+ container_name: logger1
7
+ command: >
8
+ sh -c '
9
+ while true; do
10
+ echo "[$$(date +%Y-%m-%d\ %H:%M:%S)] Container1: $$(openssl rand -hex 8)"
11
+ sleep 1
12
+ done'
13
+ restart: unless-stopped
14
+
15
+ logger2:
16
+ image: alpine:latest
17
+ container_name: logger2
18
+ command: >
19
+ sh -c '
20
+ while true; do
21
+ echo "[$$(date +%Y-%m-%d\ %H:%M:%S)] Container2: $$(openssl rand -hex 8)"
22
+ sleep 1
23
+ done'
24
+ restart: unless-stopped
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "opendocker",
3
+ "version": "0.1.0",
4
+ "module": "index.ts",
5
+ "type": "module",
6
+ "private": false,
7
+ "bin": {
8
+ "opendocker": "./src/main.tsx"
9
+ },
10
+ "file": ["src/", "package.json", "README.md"],
11
+ "scripts": {
12
+ "start": "bun run src/main.tsx",
13
+ "dev": "bun --watch run src/main.tsx"
14
+ },
15
+ "devDependencies": {
16
+ "@types/bun": "latest"
17
+ },
18
+ "peerDependencies": {
19
+ "typescript": "^5"
20
+ },
21
+ "dependencies": {
22
+ "@opentui/core": "^0.1.34",
23
+ "@opentui/react": "^0.1.34",
24
+ "zustand": "^5.0.8"
25
+ }
26
+ }
Binary file
@@ -0,0 +1,19 @@
1
+ import { colors } from "../utils/styling";
2
+
3
+ export const SplitBorder = {
4
+ border: ["left" as const],
5
+ borderColor: colors.border,
6
+ customBorderChars: {
7
+ topLeft: "",
8
+ bottomLeft: "",
9
+ vertical: "┃",
10
+ topRight: "",
11
+ bottomRight: "",
12
+ horizontal: "",
13
+ bottomT: "",
14
+ topT: "",
15
+ cross: "",
16
+ leftT: "",
17
+ rightT: "",
18
+ },
19
+ }
@@ -0,0 +1,142 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { colors, termColors } from "../utils/styling";
4
+ import Pane from "./Pane";
5
+ import { useContainerStore } from "../stores/containers";
6
+ import { useApplicationStore } from "../stores/application";
7
+ import type { ScrollBoxRenderable } from "@opentui/core";
8
+ import { Docker } from "../lib/docker";
9
+ import { TextAttributes } from "@opentui/core";
10
+
11
+ export default function ContainersPane() {
12
+ const { activePane, setActivePane } = useApplicationStore((state) => state);
13
+ const { containers, setContainers, activeContainer, setActiveContainer } = useContainerStore((state) => state);
14
+ const [selectedIndex, setSelectedIndex] = useState(0);
15
+ const paneActive = activePane === "containers";
16
+ const scrollBoxRef = useRef<ScrollBoxRenderable>(null);
17
+
18
+ useEffect(() => {
19
+ if (!paneActive) return;
20
+
21
+ const docker = new Docker();
22
+
23
+ docker.watch((dockerContainers) => {
24
+ console.log(dockerContainers[0]);
25
+ const transformed = dockerContainers.map(container => ({
26
+ name: container.Names[0].replace("/", ""),
27
+ status: container.Status,
28
+ state: container.State,
29
+ health: container.Health,
30
+ }));
31
+
32
+ setContainers(transformed);
33
+
34
+ // Get the CURRENT active container from store, not from stale closure
35
+ const currentActive = useContainerStore.getState().activeContainer;
36
+
37
+ if (currentActive) {
38
+ const stillExists = transformed.find((c) => c.name === currentActive.name);
39
+ if (stillExists) {
40
+ return;
41
+ }
42
+ }
43
+
44
+ if (transformed.length > 0) {
45
+ setActiveContainer(transformed[0]);
46
+ }
47
+ });
48
+ }, [paneActive]);
49
+
50
+ useKeyboard((key) => {
51
+ if (!paneActive) {
52
+ return;
53
+ }
54
+
55
+ if (key.name === "left") {
56
+ setActivePane("volumes");
57
+ }
58
+
59
+ if (key.name === "right" || key.name === "tab") {
60
+ setActivePane("images");
61
+ }
62
+
63
+ if (key.name === 'j' || key.name === 'down') {
64
+ const index = Math.min(selectedIndex + 1, containers.length - 1);
65
+ setSelectedIndex(index);
66
+ setActiveContainer(containers[index]);
67
+ }
68
+
69
+ if (key.name === 'k' || key.name === 'up') {
70
+ const index = Math.max(selectedIndex - 1, 0);
71
+ setActiveContainer(containers[index]);
72
+ setSelectedIndex(index);
73
+ }
74
+ });
75
+
76
+ return (
77
+ <Pane
78
+ title="Containers"
79
+ flexDirection="column"
80
+ width="100%"
81
+ active={paneActive}
82
+ >
83
+ <scrollbox
84
+ ref={scrollBoxRef}
85
+ scrollY={true}
86
+ stickyScroll={true}
87
+ stickyStart="bottom"
88
+ viewportOptions={{
89
+ flexGrow: 1
90
+ }}
91
+ >
92
+ {containers.map((item, index) => {
93
+ function getStateColor() {
94
+ if (paneActive && activeContainer?.name === item.name) {
95
+ return colors.backgroundPanel;
96
+ }
97
+
98
+ if (item?.state === "running") {
99
+ if (item.status.includes("starting")) {
100
+ return termColors.orange11;
101
+ }
102
+
103
+ if (item.status.includes("unhealthy")) {
104
+ return termColors.red11;
105
+ }
106
+
107
+ return termColors.green11;
108
+ }
109
+
110
+ if (item?.state === "exited") {
111
+ return termColors.red11;
112
+ }
113
+
114
+ return termColors.blue11;
115
+ }
116
+
117
+ return (
118
+ <box
119
+ key={index}
120
+ backgroundColor={paneActive && activeContainer?.name === item.name ? colors.primary : undefined}
121
+ flexDirection="row"
122
+ justifyContent="space-between"
123
+ paddingLeft={1}
124
+ paddingRight={1}
125
+ >
126
+ <text
127
+ content={item.name}
128
+ fg={paneActive && activeContainer?.name === item.name ? colors.backgroundPanel : colors.textMuted}
129
+ attributes={paneActive && activeContainer?.name === item.name && TextAttributes.BOLD}
130
+ />
131
+ <text
132
+ content={item.state}
133
+ fg={getStateColor()}
134
+ />
135
+ </box>
136
+ )
137
+ })}
138
+ {containers.length < 1 && <text fg={colors.textMuted}>No Containers</text>}
139
+ </scrollbox>
140
+ </Pane>
141
+ )
142
+ }
@@ -0,0 +1,83 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import Pane from "./Pane";
4
+ import { colors } from "../utils/styling";
5
+ import { useImageStore } from "../stores/images";
6
+ import { useApplicationStore } from "../stores/application";
7
+ import type { ScrollBoxRenderable } from "@opentui/core";
8
+
9
+ export default function ImagesPane() {
10
+ const { activePane, setActivePane } = useApplicationStore((state) => state);
11
+ const { images, setImages } = useImageStore();
12
+ const scrollBoxRef = useRef<ScrollBoxRenderable>(null);
13
+
14
+ useEffect(() => {
15
+ const process = Bun.spawn(
16
+ ["docker", "images", "--format", "{{.Repository}}"],
17
+ {stdout: "pipe", stderr: "pipe"},
18
+ );
19
+
20
+ async function read() {
21
+ try {
22
+ const reader = process.stdout.getReader();
23
+
24
+ while (true) {
25
+ const { done, value } = await reader.read();
26
+ if (done) break;
27
+ const decodedValues = new TextDecoder().decode(value);
28
+ const lines = decodedValues.split("\n").filter(Boolean);
29
+ setImages(lines);
30
+ }
31
+ } catch (error) {
32
+ console.error(error);
33
+ }
34
+ }
35
+
36
+ read();
37
+
38
+ return () => process.kill();
39
+ }, []);
40
+
41
+ useKeyboard((key) => {
42
+ if (activePane !== "images") {
43
+ return;
44
+ }
45
+
46
+ if (key.name === "left") {
47
+ setActivePane("containers");
48
+ }
49
+
50
+ if (key.name === "right" || key.name === "tab") {
51
+ setActivePane("volumes");
52
+ }
53
+ });
54
+
55
+ return (
56
+ <Pane
57
+ title="Images"
58
+ width="100%"
59
+ active={activePane === "images"}
60
+ flexDirection="column"
61
+ >
62
+ <scrollbox
63
+ ref={scrollBoxRef}
64
+ scrollY={true}
65
+ stickyScroll={true}
66
+ stickyStart="top"
67
+ >
68
+ {images.map((item: string, index: number) => {
69
+ return (
70
+ <box key={index}>
71
+ <text
72
+ key={index}
73
+ content={item}
74
+ fg={colors.textMuted}
75
+ />
76
+ </box>
77
+ )
78
+ })}
79
+ {images.length < 1 && <text fg={colors.textMuted}>No Images</text>}
80
+ </scrollbox>
81
+ </Pane>
82
+ )
83
+ }
@@ -0,0 +1,128 @@
1
+ import { useState, useEffect, useRef, useMemo } from "react";
2
+ import { colors, termColors } from "../utils/styling";
3
+ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
4
+ import Pane from "./Pane";
5
+ import { useContainerStore } from "../stores/containers";
6
+ import { useApplicationStore } from "../stores/application";
7
+
8
+ type LogEntry = {
9
+ text: string;
10
+ type: 'stdout' | 'stderr';
11
+ };
12
+
13
+ export default function LogsPane() {
14
+ const { activePane } = useApplicationStore((state) => state);
15
+ const { activeContainer } = useContainerStore((state) => state);
16
+ const [logs, setLogs] = useState<LogEntry[]>([]);
17
+ const scrollBoxRef = useRef<ScrollBoxRenderable>(null);
18
+
19
+ useEffect(() => {
20
+ if (!activeContainer || activePane !== "containers") {
21
+ cleanup();
22
+ return;
23
+ };
24
+
25
+ setLogs([]);
26
+ const process = Bun.spawn([
27
+ "docker",
28
+ "logs",
29
+ "--follow",
30
+ "--tail",
31
+ "100",
32
+ activeContainer.name,
33
+ ], {
34
+ stdout: "pipe",
35
+ stderr: "pipe",
36
+ });
37
+
38
+ async function readStream(stream: ReadableStream, type: 'stdout' | 'stderr') {
39
+ const decoder = new TextDecoder();
40
+ const reader = stream.getReader();
41
+
42
+ try {
43
+ while (true) {
44
+ const { done, value } = await reader.read();
45
+ if (done) break;
46
+
47
+ const text = decoder.decode(value, { stream: true });
48
+ // Split by newlines and filter out empty lines
49
+ const lines = text.split('\n').filter(line => line.trim().length > 0);
50
+
51
+ if (lines.length > 0) {
52
+ setLogs(prev => {
53
+ const newEntries = lines.map(line => ({
54
+ text: line,
55
+ type
56
+ }));
57
+ return [...prev, ...newEntries];
58
+ });
59
+ }
60
+ }
61
+ } catch (error) {}
62
+ }
63
+
64
+ readStream(process.stdout, 'stdout');
65
+ readStream(process.stderr, 'stderr');
66
+
67
+ return () => {
68
+ process.kill();
69
+ cleanup();
70
+ }
71
+ }, [activePane, activeContainer]);
72
+
73
+ useEffect(() => {
74
+ if (scrollBoxRef.current && logs.length > 0) {
75
+ scrollBoxRef.current.scrollTo({ x: 0, y: scrollBoxRef.current.scrollHeight });
76
+ }
77
+ }, [logs]);
78
+
79
+ function cleanup() {
80
+ setLogs([]);
81
+ }
82
+
83
+ const output = useMemo(() => {
84
+ if (logs.length === 0) {
85
+ return <text fg={colors.textMuted}>No logs available</text>;
86
+ }
87
+
88
+ return logs.map(log => (
89
+ <text
90
+ fg={log.type === 'stderr' ? termColors.red11 : colors.textMuted}
91
+ >
92
+ {log.text}
93
+ </text>
94
+ ));
95
+ }, [logs]);
96
+
97
+ return (
98
+ <Pane
99
+ title="Logs"
100
+ flexDirection="column"
101
+ width="70%"
102
+ >
103
+ <box flexDirection="column" gap={1} height="100%">
104
+ <box flexDirection="row" gap={2} height="auto">
105
+ <box flexDirection="column" gap={1}>
106
+ <text fg={termColors.purple11} attributes={TextAttributes.BOLD}>Container</text>
107
+ <text>{activeContainer?.name}</text>
108
+ </box>
109
+ <box flexDirection="column">
110
+ <text fg={termColors.purple11} attributes={TextAttributes.BOLD}>Status</text>
111
+ <text>{activeContainer?.status || "None"}</text>
112
+ </box>
113
+ </box>
114
+ <scrollbox
115
+ ref={scrollBoxRef}
116
+ scrollY={true}
117
+ stickyScroll={true}
118
+ stickyStart="bottom"
119
+ height="100%"
120
+ >
121
+ <box flexDirection="column" height="auto">
122
+ {output}
123
+ </box>
124
+ </scrollbox>
125
+ </box>
126
+ </Pane>
127
+ )
128
+ }
@@ -0,0 +1,34 @@
1
+ import { SplitBorder } from "./Border";
2
+ import type { BoxProps } from "@opentui/react";
3
+ import { padding, colors, termColors } from "../utils/styling";
4
+ import { TextAttributes } from "@opentui/core";
5
+
6
+ interface PaneProps extends BoxProps {
7
+ title: string;
8
+ active?: boolean;
9
+ children: React.ReactNode;
10
+ }
11
+
12
+ export default function Pane({
13
+ title,
14
+ active = false,
15
+ children,
16
+ ...props
17
+ }: PaneProps) {
18
+ return (
19
+ <box
20
+ {...SplitBorder}
21
+ {...props}
22
+ borderColor={active ? termColors.purple11 : colors.backgroundPanel}
23
+ >
24
+ <box
25
+ flexGrow={1}
26
+ backgroundColor={colors.backgroundPanel}
27
+ {...padding}
28
+ >
29
+ <text marginBottom={1} attributes={TextAttributes.BOLD}>{title}</text>
30
+ {children}
31
+ </box>
32
+ </box>
33
+ )
34
+ }
@@ -0,0 +1,84 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import Pane from "./Pane";
4
+ import { colors } from "../utils/styling";
5
+ import { useVolumeStore } from "../stores/volumes";
6
+ import { useApplicationStore } from "../stores/application";
7
+ import type { ScrollBoxRenderable } from "@opentui/core";
8
+
9
+ export default function ImagesPane() {
10
+ const { activePane, setActivePane } = useApplicationStore((state) => state);
11
+ const { volumes, setVolumes } = useVolumeStore();
12
+ const scrollBoxRef = useRef<ScrollBoxRenderable>(null);
13
+
14
+ useEffect(() => {
15
+ const process = Bun.spawn(
16
+ ["docker", "volume", "ls", "--format", "{{.Name}}"],
17
+ {stdout: "pipe", stderr: "pipe"},
18
+ );
19
+
20
+ async function read() {
21
+ try {
22
+ const reader = process.stdout.getReader();
23
+
24
+ while (true) {
25
+ const { done, value } = await reader.read();
26
+ if (done) break;
27
+ const decodedValues = new TextDecoder().decode(value);
28
+ const lines = decodedValues.split("\n").filter(Boolean);
29
+ setVolumes(lines);
30
+ }
31
+ } catch (error) {
32
+ console.error(error);
33
+ }
34
+ }
35
+
36
+ read();
37
+
38
+ return () => process.kill();
39
+ }, []);
40
+
41
+ useKeyboard((key) => {
42
+ if (activePane !== "volumes") {
43
+ return;
44
+ }
45
+
46
+ if (key.name === "left") {
47
+ setActivePane("images");
48
+ }
49
+
50
+ if (key.name === "right" || key.name === "tab") {
51
+ setActivePane("containers");
52
+ }
53
+ });
54
+
55
+ return (
56
+ <Pane
57
+ title="Volumes"
58
+ active={activePane === "volumes"}
59
+ width="100%"
60
+ flexDirection="column"
61
+ >
62
+ <scrollbox
63
+ ref={scrollBoxRef}
64
+ scrollY={true}
65
+ stickyScroll={true}
66
+ stickyStart="bottom"
67
+ viewportOptions={{
68
+ flexGrow: 1
69
+ }}
70
+ >
71
+ {volumes.map((item, index) => {
72
+ return <box>
73
+ <text
74
+ key={index}
75
+ content={`[*] ${item}`}
76
+ fg={colors.textMuted}
77
+ />
78
+ </box>
79
+ })}
80
+ {volumes.length < 1 && <text fg={colors.textMuted}>No Volumes</text>}
81
+ </scrollbox>
82
+ </Pane>
83
+ )
84
+ }
@@ -0,0 +1,60 @@
1
+
2
+ import { useTerminalDimensions } from "@opentui/react";
3
+ import { useState, useEffect } from "react";
4
+ import { colors, padding } from "../utils/styling";
5
+ import { TextAttributes } from "@opentui/core";
6
+ import { useApplicationStore } from "../stores/application";
7
+ import { SplitBorder } from "../components/Border";
8
+
9
+ export default function BaseLayout({ children }: { children: React.ReactNode }) {
10
+ const { activePane, setActivePane } = useApplicationStore((state) => state);
11
+ const dimensions = useTerminalDimensions();
12
+ const [pwd, setPwd] = useState<string>("");
13
+
14
+ useEffect(() => {
15
+ Bun.$`pwd`.quiet().then(result => setPwd(result.text()));
16
+ setActivePane("containers");
17
+
18
+ }, [setActivePane]);
19
+
20
+ return (
21
+ <box
22
+ width={dimensions.width}
23
+ height={dimensions.height}
24
+ backgroundColor={colors.background}
25
+ >
26
+ <box
27
+ flexDirection="row"
28
+ gap={1}
29
+ width="100%"
30
+ {...padding}
31
+ >
32
+ {children}
33
+ </box>
34
+ <box
35
+ height={1}
36
+ backgroundColor={colors.backgroundPanel}
37
+ flexDirection="row"
38
+ justifyContent="space-between"
39
+ flexShrink={0}
40
+ >
41
+ <box flexDirection="row">
42
+ <box flexDirection="row" backgroundColor={colors.backgroundElement} paddingLeft={1} paddingRight={1}>
43
+ <text fg={colors.textMuted}>open</text>
44
+ <text attributes={TextAttributes.BOLD}>docker </text>
45
+ <text fg={colors.textMuted}>alpha</text>
46
+ </box>
47
+ <box paddingLeft={1} paddingRight={1}>
48
+ <text fg={colors.textMuted}>~{pwd}</text>
49
+ </box>
50
+ </box>
51
+ <box flexDirection="row" gap={1}>
52
+ <text fg={colors.textMuted}>tab</text>
53
+ <box backgroundColor={colors.accent} paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={colors.backgroundPanel}>
54
+ <text fg={colors.backgroundPanel} attributes={TextAttributes.BOLD}>{activePane}</text>
55
+ </box>
56
+ </box>
57
+ </box>
58
+ </box>
59
+ )
60
+ }
@@ -0,0 +1,68 @@
1
+
2
+ export class Docker {
3
+ private socket = "/var/run/docker.sock";
4
+
5
+ private async request(path: string) {
6
+ try {
7
+ const process = Bun.spawn([
8
+ "curl", "-s", "--unix-socket", this.socket,
9
+ `http://localhost${path}`
10
+ ], { stdout: "pipe", stderr: "pipe" });
11
+
12
+ const output = await new Response(process.stdout).text();
13
+ process.kill();
14
+
15
+ return JSON.parse(output);
16
+ } catch (error) {
17
+ console.error('Docker socket request failed:', error);
18
+ throw error;
19
+ }
20
+ }
21
+
22
+ async getContainers() {
23
+ return this.request("/v1.41/containers/json?all=true");
24
+ }
25
+
26
+ async watch(callback: (containers: any[]) => void) {
27
+ try {
28
+ let containers = await this.getContainers();
29
+ callback(containers);
30
+
31
+ const process = Bun.spawn([
32
+ "curl", "-s", "--no-buffer", "--unix-socket", this.socket,
33
+ "http://localhost/v1.41/events"
34
+ ], { stdout: "pipe", stderr: "pipe" });
35
+
36
+ const reader = process.stdout.getReader();
37
+ const decoder = new TextDecoder();
38
+ let buffer = "";
39
+
40
+ while (true) {
41
+ const { value, done } = await reader.read();
42
+ if (done) break;
43
+
44
+ buffer += decoder.decode(value, { stream: true });
45
+ const lines = buffer.split('\n');
46
+ buffer = lines.pop() || "";
47
+
48
+ for (const line of lines) {
49
+ if (line.trim()) {
50
+ try {
51
+ const event = JSON.parse(line);
52
+ if (event.Type === "container") {
53
+ containers = await this.getContainers();
54
+ callback(containers);
55
+ }
56
+ } catch (parseError) {
57
+ console.error('Failed to parse event:', line, parseError);
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ } catch (error) {
64
+ console.error('Docker watch failed', error);
65
+ throw error;
66
+ }
67
+ }
68
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { useRenderer, render, useKeyboard } from "@opentui/react";
4
+ import ContainersPane from "./components/ContainersPane";
5
+ import LogsPane from "./components/LogsPane";
6
+ import ImagesPane from "./components/ImagesPane";
7
+ import VolumesPane from "./components/VolumesPanes";
8
+ import BaseLayout from "./layouts/BaseLayout";
9
+
10
+ function App() {
11
+ const renderer = useRenderer();
12
+
13
+ useKeyboard((key) => {
14
+ if (key.name === "q") {
15
+ process.exit(0);
16
+ }
17
+
18
+ if (key.ctrl && key.name === "k") {
19
+ renderer?.console.toggle()
20
+ renderer?.toggleDebugOverlay()
21
+ }
22
+ })
23
+
24
+ return (
25
+ <BaseLayout>
26
+ <box
27
+ flexDirection="column"
28
+ width="30%"
29
+ gap={1}
30
+ >
31
+ <ContainersPane />
32
+ <ImagesPane />
33
+ <VolumesPane />
34
+ </box>
35
+ <LogsPane />
36
+ </BaseLayout>
37
+ )
38
+ }
39
+
40
+ render (<App />);
@@ -0,0 +1,11 @@
1
+ import { create } from "zustand";
2
+
3
+ interface ApplicationStore {
4
+ activePane: string;
5
+ setActivePane: (activePane: string) => void;
6
+ }
7
+
8
+ export const useApplicationStore = create<ApplicationStore>((set) => ({
9
+ activePane: "containers",
10
+ setActivePane: (activePane) => set({ activePane }),
11
+ }));
@@ -0,0 +1,22 @@
1
+ import { create } from "zustand";
2
+
3
+ export type Container = {
4
+ name: string;
5
+ status: string;
6
+ health: string;
7
+ state: string;
8
+ }
9
+
10
+ interface ContainerStore {
11
+ containers: Container[];
12
+ activeContainer: Container;
13
+ setContainers: (containers: Container[]) => void;
14
+ setActiveContainer: (activeContainer: Container) => void;
15
+ }
16
+
17
+ export const useContainerStore = create<ContainerStore>((set) => ({
18
+ containers: [],
19
+ activeContainer: null,
20
+ setContainers: (containers: Container[]) => set({ containers }),
21
+ setActiveContainer: (activeContainer) => set({ activeContainer }),
22
+ }));
@@ -0,0 +1,11 @@
1
+ import { create } from "zustand";
2
+
3
+ export interface ImageStore {
4
+ images: string[];
5
+ setImages: (images: string[]) => void;
6
+ }
7
+
8
+ export const useImageStore = create<ImageStore>((set) => ({
9
+ images: [],
10
+ setImages: (images: string[]) => set({ images }),
11
+ }));
@@ -0,0 +1,11 @@
1
+ import { create } from "zustand";
2
+
3
+ interface VolumeStore {
4
+ volumes: string[];
5
+ setVolumes: (volumes: string[]) => void;
6
+ }
7
+
8
+ export const useVolumeStore = create<VolumeStore>((set) => ({
9
+ volumes: [],
10
+ setVolumes: (volumes: string[]) => set({ volumes }),
11
+ }));
@@ -0,0 +1,198 @@
1
+ export const colors = {
2
+ primary: '#fab283',
3
+ secondary: '#5c9cf5',
4
+ accent: '#9d7cd8',
5
+ error: '#e06c75',
6
+ warning: '#f5a742',
7
+ success: '#7fd88f',
8
+ info: '#56b6c2',
9
+ text: '#eeeeee',
10
+ textMuted: '#808080',
11
+ background: '#0a0a0a',
12
+ backgroundPanel: '#141414',
13
+ backgroundElement: '#1e1e1e',
14
+ border: '#484848',
15
+ borderActive: '#606060',
16
+ borderSubtle: '#3c3c3c',
17
+ }
18
+
19
+ export const padding = {
20
+ paddingTop: 1,
21
+ paddingBottom: 1,
22
+ paddingLeft: 2,
23
+ paddingRight: 2,
24
+ }
25
+
26
+ export const termColors = {
27
+ gray01: "#111111",
28
+ gray02: "#191919",
29
+ gray03: "#222222",
30
+ gray04: "#2a2a2a",
31
+ gray05: "#313131",
32
+ gray06: "#3a3a3a",
33
+ gray07: "#484848",
34
+ gray08: "#606060",
35
+ gray09: "#6e6e6e",
36
+ gray10: "#7b7b7b",
37
+ gray11: "#b4b4b4",
38
+ gray12: "#eeeeee",
39
+ gray13: "#ffffff",
40
+ gray1: "#0b0b0b",
41
+ gray00: "#0d0d0d",
42
+ red01: "#191111",
43
+ red02: "#201314",
44
+ red03: "#3b1219",
45
+ red04: "#500f1c",
46
+ red05: "#611623",
47
+ red06: "#72232d",
48
+ red07: "#8c333a",
49
+ red08: "#b54548",
50
+ red09: "#e5484d",
51
+ red10: "#ec5d5e",
52
+ red11: "#ff9592",
53
+ red12: "#ffd1d9",
54
+ green01: "#0e1512",
55
+ green02: "#121b17",
56
+ green03: "#132d21",
57
+ green04: "#113b29",
58
+ green05: "#174933",
59
+ green06: "#20573e",
60
+ green07: "#28684a",
61
+ green08: "#2f7c57",
62
+ green09: "#30a46c",
63
+ green10: "#33b074",
64
+ green11: "#3dd68c",
65
+ green12: "#b1f1cb",
66
+ blue01: "#0d1520",
67
+ blue02: "#111927",
68
+ blue03: "#0d2847",
69
+ blue04: "#003362",
70
+ blue05: "#004074",
71
+ blue06: "#104d87",
72
+ blue07: "#205d9e",
73
+ blue08: "#2870bd",
74
+ blue09: "#0090ff",
75
+ blue10: "#3b9eff",
76
+ blue11: "#70b8ff",
77
+ blue12: "#c2e6ff",
78
+ orange01: "#17120e",
79
+ orange02: "#1e160f",
80
+ orange03: "#331e0b",
81
+ orange04: "#462100",
82
+ orange05: "#562800",
83
+ orange06: "#66350c",
84
+ orange07: "#7e451d",
85
+ orange08: "#a35829",
86
+ orange09: "#f76b15",
87
+ orange10: "#ff801f",
88
+ orange11: "#ffa057",
89
+ orange12: "#ffe0c2",
90
+ purple01: "#18111b",
91
+ purple02: "#1e1523",
92
+ purple03: "#301c3b",
93
+ purple04: "#3d224e",
94
+ purple05: "#48295c",
95
+ purple06: "#54346b",
96
+ purple07: "#664282",
97
+ purple08: "#8457aa",
98
+ purple09: "#8e4ec6",
99
+ purple10: "#9a5cd0",
100
+ purple11: "#d19dff",
101
+ purple12: "#ecd9fa",
102
+ teal01: "#0d1514",
103
+ teal02: "#111c1b",
104
+ teal03: "#0d2d2a",
105
+ teal04: "#023b37",
106
+ teal05: "#084843",
107
+ teal06: "#145750",
108
+ teal07: "#1c6961",
109
+ teal08: "#207e73",
110
+ teal09: "#12a594",
111
+ teal10: "#0eb39e",
112
+ teal11: "#0bd8b6",
113
+ teal12: "#adf0dd",
114
+ yellow01: "#14120b",
115
+ yellow02: "#1b180f",
116
+ yellow03: "#2d2305",
117
+ yellow04: "#362b00",
118
+ yellow05: "#433500",
119
+ yellow06: "#524202",
120
+ yellow07: "#665417",
121
+ yellow08: "#836a21",
122
+ yellow09: "#ffe629",
123
+ yellow10: "#ffff57",
124
+ yellow11: "#f5e147",
125
+ yellow12: "#f6eeb4",
126
+ pink01: "#191117",
127
+ pink02: "#21121d",
128
+ pink03: "#37172f",
129
+ pink04: "#4b143d",
130
+ pink05: "#591c47",
131
+ pink06: "#692955",
132
+ pink07: "#833869",
133
+ pink08: "#a84885",
134
+ pink09: "#d6409f",
135
+ pink10: "#de51a8",
136
+ pink11: "#ff8dcc",
137
+ pink12: "#fdd1ea",
138
+ brown01: "#12110f",
139
+ brown02: "#1c1816",
140
+ brown03: "#28211d",
141
+ brown04: "#322922",
142
+ brown05: "#3e3128",
143
+ brown06: "#4d3c2f",
144
+ brown07: "#614a39",
145
+ brown08: "#7c5f46",
146
+ brown09: "#ad7f58",
147
+ brown10: "#b88c67",
148
+ brown11: "#dbb594",
149
+ brown12: "#f2e1ca",
150
+ iris01: "#13131e",
151
+ iris02: "#171625",
152
+ iris03: "#202248",
153
+ iris04: "#262a65",
154
+ iris05: "#303374",
155
+ iris06: "#3d3e82",
156
+ iris07: "#4a4a95",
157
+ iris08: "#5958b1",
158
+ iris09: "#5b5bd6",
159
+ iris10: "#6e6ade",
160
+ iris11: "#b1a9ff",
161
+ iris12: "#e0dffe",
162
+ lime01: "#11130c",
163
+ lime02: "#151a10",
164
+ lime03: "#1f2917",
165
+ lime04: "#29371d",
166
+ lime05: "#334423",
167
+ lime06: "#3d522a",
168
+ lime07: "#496231",
169
+ lime08: "#577538",
170
+ lime09: "#bdee63",
171
+ lime10: "#d4ff70",
172
+ lime11: "#bde56c",
173
+ lime12: "#e3f7ba",
174
+ amber01: "#16120c",
175
+ amber02: "#1d180f",
176
+ amber03: "#302008",
177
+ amber04: "#3f2700",
178
+ amber05: "#4d3000",
179
+ amber06: "#5c3d05",
180
+ amber07: "#714f19",
181
+ amber08: "#8f6424",
182
+ amber09: "#ffc53d",
183
+ amber10: "#ffd60a",
184
+ amber11: "#ffca16",
185
+ amber12: "#ffe7b3",
186
+ tomato01: "#181111",
187
+ tomato02: "#1f1513",
188
+ tomato03: "#391714",
189
+ tomato04: "#4e1511",
190
+ tomato05: "#5e1c16",
191
+ tomato06: "#6e2920",
192
+ tomato07: "#853a2d",
193
+ tomato08: "#ac4d39",
194
+ tomato09: "#e54d2e",
195
+ tomato10: "#ec6142",
196
+ tomato11: "#ff977d",
197
+ tomato12: "#fbd3cb",
198
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "@tsconfig/bun/tsconfig.json",
4
+ "compilerOptions": {
5
+ "jsx": "preserve",
6
+ "jsxImportSource": "@opentui/react",
7
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
8
+ "customConditions": ["browser"],
9
+ "baseUrl": ".",
10
+ "paths": {
11
+ "@/*": ["./src/*"],
12
+ "@tui/*": ["./src/cli/cmd/tui/*"]
13
+ }
14
+ }
15
+ }