never executed always true always false
1 module PureClaw.Handles.Harness
2 ( -- * Types
3 HarnessStatus (..)
4 , HarnessHandle (..)
5 , HarnessError (..)
6 -- * Implementations
7 , mkNoOpHarnessHandle
8 -- * Output formatting
9 , prefixHarnessOutput
10 , sanitizeHarnessOutput
11 ) where
12
13 import Data.ByteString (ByteString)
14 import Data.Char qualified as Char
15 import Data.Text (Text)
16 import Data.Text qualified as T
17 import System.Exit
18
19 import PureClaw.Security.Command
20
21 -- | Status of a harness process.
22 data HarnessStatus
23 = HarnessRunning
24 | HarnessExited ExitCode
25 deriving stock (Show, Eq)
26
27 -- | Errors that can occur during harness operations.
28 data HarnessError
29 = HarnessNotAuthorized CommandError
30 | HarnessBinaryNotFound Text
31 | HarnessTmuxNotAvailable Text -- ^ detail message (stderr from tmux, or "not found")
32 deriving stock (Show, Eq)
33
34 -- | Capability handle for interacting with a harness (e.g. Claude Code in tmux).
35 data HarnessHandle = HarnessHandle
36 { _hh_send :: ByteString -> IO () -- ^ Write to harness input
37 , _hh_receive :: IO ByteString -- ^ Read harness output (scrollback capture)
38 , _hh_name :: Text -- ^ Human-readable name
39 , _hh_session :: Text -- ^ tmux session name
40 , _hh_status :: IO HarnessStatus -- ^ Check if running
41 , _hh_stop :: IO () -- ^ Kill and cleanup
42 }
43
44 -- | No-op harness handle for testing.
45 mkNoOpHarnessHandle :: HarnessHandle
46 mkNoOpHarnessHandle = HarnessHandle
47 { _hh_send = \_ -> pure ()
48 , _hh_receive = pure ""
49 , _hh_name = ""
50 , _hh_session = ""
51 , _hh_status = pure HarnessRunning
52 , _hh_stop = pure ()
53 }
54
55 -- | Prefix harness output with the origin name on the first line only.
56 -- e.g. @"claude-code-0\> line1\\nline2\\nline3"@.
57 -- This is the single abstraction for displaying messages from a harness\/model.
58 prefixHarnessOutput :: Text -> Text -> Text
59 prefixHarnessOutput name output = name <> "> " <> output
60
61 -- | Sanitize harness output for display in a TUI.
62 -- Strips ANSI escape sequences (CSI, OSC, DCS, etc.), C0\/C1 control
63 -- characters, and decorative Unicode (box drawing, block elements,
64 -- Private Use Area, etc.) that TUI applications use for rendering.
65 -- Also trims leading and trailing blank lines from tmux capture output.
66 sanitizeHarnessOutput :: Text -> Text
67 sanitizeHarnessOutput =
68 trimBlankLines . T.pack . go . T.unpack
69 where
70 trimBlankLines =
71 T.intercalate "\n"
72 . dropWhileEnd isBlankLine
73 . dropWhile isBlankLine
74 . T.splitOn "\n"
75
76 isBlankLine = T.all Char.isSpace
77
78 dropWhileEnd _ [] = []
79 dropWhileEnd p xs = reverse (dropWhile p (reverse xs))
80
81 go [] = []
82 go ('\ESC' : rest) = skipEscape rest
83 -- Keep newlines and tabs
84 go ('\n' : cs) = '\n' : go cs
85 go ('\t' : cs) = '\t' : go cs
86 -- Replace carriage return with newline (handles \r\n and bare \r)
87 go ('\r' : '\n' : cs) = '\n' : go cs
88 go ('\r' : cs) = '\n' : go cs
89 -- Drop control characters, then decorative Unicode
90 go (c : cs)
91 | Char.isControl c = go cs
92 | isDecorativeChar c = go cs
93 | otherwise = c : go cs
94
95 -- Skip ESC [ ... (final byte) — CSI sequences
96 skipEscape ('[' : cs) = skipCsi cs
97 -- Skip ESC ] ... ST — OSC sequences (terminated by BEL or ESC \)
98 skipEscape (']' : cs) = skipOsc cs
99 -- Skip ESC P ... ST — DCS sequences
100 skipEscape ('P' : cs) = skipOsc cs
101 -- Skip ESC ( X, ESC ) X — charset designators
102 skipEscape ('(' : _ : cs) = go cs
103 skipEscape (')' : _ : cs) = go cs
104 -- Skip ESC followed by any single character (SS2, SS3, etc.)
105 skipEscape (_ : cs) = go cs
106 skipEscape [] = []
107
108 -- CSI: skip parameter bytes (0x30-0x3F) and intermediate bytes (0x20-0x2F)
109 -- until a final byte (0x40-0x7E)
110 skipCsi [] = []
111 skipCsi (c : cs)
112 | c >= '@' && c <= '~' = go cs -- final byte, done
113 | otherwise = skipCsi cs
114
115 -- OSC / DCS: skip until BEL (0x07) or ST (ESC \)
116 skipOsc [] = []
117 skipOsc ('\BEL' : cs) = go cs
118 skipOsc ('\ESC' : '\\' : cs) = go cs
119 skipOsc (_ : cs) = skipOsc cs
120
121 -- | Characters used by TUI applications for rendering decorative elements.
122 -- These are valid Unicode but produce visual garbage when displayed outside
123 -- the originating terminal application.
124 isDecorativeChar :: Char -> Bool
125 isDecorativeChar c = let cp = Char.ord c in
126 -- Box Drawing (U+2500–U+257F)
127 (cp >= 0x2500 && cp <= 0x257F)
128 -- Block Elements (U+2580–U+259F)
129 || (cp >= 0x2580 && cp <= 0x259F)
130 -- Geometric Shapes (U+25A0–U+25FF) — squares, circles, triangles
131 || (cp >= 0x25A0 && cp <= 0x25FF)
132 -- Braille Patterns (U+2800–U+28FF) — used for sparklines/graphs
133 || (cp >= 0x2800 && cp <= 0x28FF)
134 -- Private Use Area (U+E000–U+F8FF) — Powerline, Nerd Font icons
135 || (cp >= 0xE000 && cp <= 0xF8FF)
136 -- Supplementary Private Use Areas (U+F0000–U+10FFFF)
137 || cp >= 0xF0000