|
| 1 | +--- |
| 2 | +name: terminal |
| 3 | +description: TimeWarp.Terminal library - console abstractions (IConsole, ITerminal), widgets (panels, tables, rules), ANSI colors, hyperlinks, and Unicode width handling for testable C# console apps |
| 4 | +--- |
| 5 | + |
| 6 | +# TimeWarp.Terminal |
| 7 | + |
| 8 | +Console abstractions and widgets for testable C# applications. |
| 9 | + |
| 10 | +**Repository:** https://github.com/TimeWarpEngineering/timewarp-terminal |
| 11 | +**Package:** `TimeWarp.Terminal` |
| 12 | + |
| 13 | +For detailed API documentation, fetch the README from the repository. |
| 14 | + |
| 15 | +## When to Use What |
| 16 | + |
| 17 | +| Need | Use | |
| 18 | +|------|-----| |
| 19 | +| Basic testable I/O | `IConsole` | |
| 20 | +| Rich output (colors, widgets, hyperlinks) | `ITerminal` | |
| 21 | +| Quick utility script, easy Console migration | `Terminal` static class | |
| 22 | +| Unit testing | `TestTerminal` or `TestConsole` | |
| 23 | +| Measure terminal display width | `UnicodeWidth` | |
| 24 | +| Strip/measure ANSI strings | `AnsiStringUtils` | |
| 25 | + |
| 26 | +## Installation |
| 27 | + |
| 28 | +```bash |
| 29 | +dotnet add package TimeWarp.Terminal |
| 30 | +``` |
| 31 | + |
| 32 | +## Core Pattern: Dependency Injection |
| 33 | + |
| 34 | +```csharp |
| 35 | +// Production |
| 36 | +services.AddSingleton<ITerminal>(TimeWarpTerminal.Default); |
| 37 | + |
| 38 | +// Testing |
| 39 | +services.AddSingleton<ITerminal>(new TestTerminal()); |
| 40 | +``` |
| 41 | + |
| 42 | +## Quick Examples |
| 43 | + |
| 44 | +### Basic Output |
| 45 | + |
| 46 | +```csharp |
| 47 | +using TimeWarp.Terminal; |
| 48 | + |
| 49 | +// Static API (Console replacement) |
| 50 | +Terminal.WriteLine("Hello".Green()); |
| 51 | +Terminal.WriteLine("Warning".Yellow().Bold()); |
| 52 | + |
| 53 | +// Instance API (testable) — all Write methods return ITerminal for fluent chaining |
| 54 | +ITerminal terminal = TimeWarpTerminal.Default; |
| 55 | +terminal |
| 56 | + .WriteLine("Hello".Cyan()) |
| 57 | + .WriteRule("Section") |
| 58 | + .WriteLine("World".Green()); |
| 59 | +``` |
| 60 | + |
| 61 | +### Widgets |
| 62 | + |
| 63 | +All widgets use the builder pattern. Constructors for `Table`, `Panel`, and `Rule` are internal — always use the builder or `Action<XxxBuilder>` overloads. |
| 64 | + |
| 65 | +```csharp |
| 66 | +// Panel — simple |
| 67 | +terminal.WritePanel("Content here"); |
| 68 | + |
| 69 | +// Panel — with header parameter |
| 70 | +terminal.WritePanel("Content here", "Title"); |
| 71 | + |
| 72 | +// Panel with builder |
| 73 | +terminal.WritePanel(panel => panel |
| 74 | + .Header("Configuration") |
| 75 | + .Content("Setting: value") |
| 76 | + .Border(BorderStyle.Rounded) |
| 77 | + .Padding(2, 1)); |
| 78 | + |
| 79 | +// Table |
| 80 | +terminal.WriteTable(table => table |
| 81 | + .AddColumn("Name") |
| 82 | + .AddColumn("Status", Alignment.Right) |
| 83 | + .AddRow("API", "Online".Green()) |
| 84 | + .AddRow("DB", "Offline".Red()) |
| 85 | + .Border(BorderStyle.Rounded)); |
| 86 | + |
| 87 | +// Standalone builder (when you need the Table object) |
| 88 | +Table table = new TableBuilder() |
| 89 | + .AddColumn("Name") |
| 90 | + .AddRow("foo") |
| 91 | + .Border(BorderStyle.Rounded) |
| 92 | + .Build(); |
| 93 | +terminal.WriteTable(table); |
| 94 | + |
| 95 | +// Rule (section divider) |
| 96 | +terminal.WriteRule("Section Title".Cyan()); |
| 97 | + |
| 98 | +// Rule with style |
| 99 | +terminal.WriteRule("Configuration", style: LineStyle.Doubled); |
| 100 | + |
| 101 | +// Rule with builder |
| 102 | +terminal.WriteRule(rule => rule |
| 103 | + .Title("Configuration") |
| 104 | + .Style(LineStyle.Doubled) |
| 105 | + .Color(AnsiColors.Cyan)); |
| 106 | +``` |
| 107 | + |
| 108 | +### Hyperlinks |
| 109 | + |
| 110 | +```csharp |
| 111 | +// String extension — creates OSC 8 hyperlink |
| 112 | +string link = "Click here".Link("https://example.com"); |
| 113 | + |
| 114 | +// With styling |
| 115 | +string styledLink = "Docs".Link("https://docs.example.com").Blue().Underline(); |
| 116 | + |
| 117 | +// Terminal extension methods (gracefully degrade if unsupported) |
| 118 | +terminal.WriteLink("https://example.com", "Click here"); // no newline |
| 119 | +terminal.WriteLinkLine("https://example.com", "Visit site"); // with newline |
| 120 | +
|
| 121 | +// Static API |
| 122 | +Terminal.WriteLink("https://example.com", "Click here"); |
| 123 | + |
| 124 | +// Check support |
| 125 | +if (terminal.SupportsHyperlinks) |
| 126 | + terminal.WriteLinkLine("https://example.com", "Link"); |
| 127 | +else |
| 128 | + terminal.WriteLine("https://example.com"); |
| 129 | + |
| 130 | +// Low-level |
| 131 | +string osc8 = AnsiHyperlinks.CreateLink("text", "https://example.com"); |
| 132 | +``` |
| 133 | + |
| 134 | +### Colors and Styles |
| 135 | + |
| 136 | +```csharp |
| 137 | +// Colors: Black, Red, Green, Yellow, Blue, Magenta, Cyan, White, Gray |
| 138 | +// Bright variants: BrightRed, BrightGreen, etc. |
| 139 | +// Styles: Bold(), Dim(), Italic(), Underline(), Strikethrough() |
| 140 | +// Background: OnRed(), OnGreen(), OnYellow(), etc. |
| 141 | +
|
| 142 | +terminal.WriteLine("Success".Green().Bold()); |
| 143 | +terminal.WriteLine("Error".Red().OnWhite()); |
| 144 | + |
| 145 | +// ConsoleColor overloads on static API and WritePanel |
| 146 | +Terminal.WriteLine("colored", ConsoleColor.Green); |
| 147 | +Terminal.WriteLine("colored", ConsoleColor.Red, ConsoleColor.White); |
| 148 | +``` |
| 149 | + |
| 150 | +### Unicode Width |
| 151 | + |
| 152 | +Calculates terminal display width accounting for emoji, CJK, fullwidth forms, and zero-width characters. Uses exact Emoji_Presentation=Yes code points from Unicode 16.0. |
| 153 | + |
| 154 | +```csharp |
| 155 | +UnicodeWidth.GetTextWidth("Hello"); // 5 (ASCII) |
| 156 | +UnicodeWidth.GetTextWidth("📍"); // 2 (emoji) |
| 157 | +UnicodeWidth.GetTextWidth("漢字"); // 4 (CJK) |
| 158 | +UnicodeWidth.GetTextWidth("📍 Location"); // 11 (mixed) |
| 159 | +UnicodeWidth.GetTextWidth("🇺🇸"); // 2 (flag - multi-codepoint cluster) |
| 160 | +UnicodeWidth.GetTextWidth("☁️"); // 2 (text char + VS16) |
| 161 | +
|
| 162 | +UnicodeWidth.GetRuneWidth(new Rune('A')); // 1 |
| 163 | +UnicodeWidth.GetRuneWidth(new Rune('漢')); // 2 |
| 164 | +UnicodeWidth.GetRuneWidth(new Rune(0x200D)); // 0 (ZWJ) |
| 165 | +``` |
| 166 | + |
| 167 | +### Testing |
| 168 | + |
| 169 | +```csharp |
| 170 | +// Create test terminal with scripted input |
| 171 | +using TestTerminal terminal = new("yes\n"); |
| 172 | + |
| 173 | +// Run code under test |
| 174 | +MyCommand command = new(terminal); |
| 175 | +command.Execute(); |
| 176 | + |
| 177 | +// Verify output |
| 178 | +Assert.Contains("expected text", terminal.Output); |
| 179 | +Assert.Contains("error message", terminal.ErrorOutput); |
| 180 | +``` |
| 181 | + |
| 182 | +### Static Terminal Testing |
| 183 | + |
| 184 | +```csharp |
| 185 | +using TestTerminal testTerminal = new(); |
| 186 | +Terminal.Instance = testTerminal; |
| 187 | +try |
| 188 | +{ |
| 189 | + Terminal.WriteLine("test"); |
| 190 | + Assert.Contains("test", testTerminal.Output); |
| 191 | +} |
| 192 | +finally |
| 193 | +{ |
| 194 | + Terminal.Instance = TimeWarpTerminal.Default; |
| 195 | +} |
| 196 | +``` |
| 197 | + |
| 198 | +## Key Interfaces |
| 199 | + |
| 200 | +**IConsole:** `Write` -> `IConsole`, `WriteLine` -> `IConsole`, `WriteLineAsync` -> `Task`, `WriteErrorLine` -> `IConsole`, `WriteErrorLineAsync` -> `Task`, `ReadLine` |
| 201 | + |
| 202 | +**ITerminal extends IConsole:** Overrides `Write` -> `ITerminal`, `WriteLine` -> `ITerminal`, `WriteErrorLine` -> `ITerminal`. Adds `ReadKey`, `SetCursorPosition`, `GetCursorPosition`, `WindowWidth`, `IsInteractive`, `SupportsColor`, `SupportsHyperlinks`, `Clear` |
| 203 | + |
| 204 | +All Write methods return the interface for fluent chaining: |
| 205 | +```csharp |
| 206 | +terminal |
| 207 | + .WriteLine("Build Output") |
| 208 | + .WriteRule("Results") |
| 209 | + .WriteTable(t => t |
| 210 | + .AddColumn("Test") |
| 211 | + .AddColumn("Status") |
| 212 | + .AddRow("Unit", "PASSED".Green())) |
| 213 | + .WriteRule() |
| 214 | + .WriteLine("Done!"); |
| 215 | +``` |
| 216 | + |
| 217 | +## Widget Builder API |
| 218 | + |
| 219 | +Widget constructors are internal. Always use builders. |
| 220 | + |
| 221 | +**PanelBuilder:** `.Header()`, `.Content(string?)`, `.Border()`, `.BorderColor()`, `.Padding()`, `.PaddingHorizontal()`, `.PaddingVertical()`, `.Width()`, `.WordWrap()`, `.Build()` |
| 222 | + |
| 223 | +**TableBuilder:** `.AddColumn(name)`, `.AddColumn(name, alignment)`, `.AddColumn(TableColumn)`, `.AddColumns(params)`, `.AddRow(params)`, `.Border()`, `.BorderColor()`, `.HideHeaders()`, `.ShowRowSeparators()`, `.Expand()`, `.Shrink()`, `.Build()` |
| 224 | + |
| 225 | +**RuleBuilder:** `.Title()`, `.Style()`, `.Color()`, `.Width()`, `.Build()` |
| 226 | + |
| 227 | +### WritePanel Overloads |
| 228 | + |
| 229 | +```csharp |
| 230 | +// Simple content |
| 231 | +terminal.WritePanel("Content"); |
| 232 | +terminal.WritePanel("Content", BorderStyle.Rounded); |
| 233 | + |
| 234 | +// With header parameter |
| 235 | +terminal.WritePanel("Content", "Header"); |
| 236 | +terminal.WritePanel("Content", "Header", BorderStyle.Doubled); |
| 237 | + |
| 238 | +// With colors |
| 239 | +terminal.WritePanel("Content", "Header", BorderStyle.Rounded, ConsoleColor.Green, ConsoleColor.Black); |
| 240 | + |
| 241 | +// Builder pattern |
| 242 | +terminal.WritePanel(p => p.Header("Title").Content("Body").Border(BorderStyle.Rounded)); |
| 243 | + |
| 244 | +// Pre-built panel |
| 245 | +terminal.WritePanel(panel); |
| 246 | +``` |
| 247 | + |
| 248 | +### TableColumn Properties |
| 249 | + |
| 250 | +| Property | Type | Default | Description | |
| 251 | +|----------|------|---------|-------------| |
| 252 | +| `Header` | `string` | `""` | Column header text (supports ANSI colors) | |
| 253 | +| `Alignment` | `Alignment` | `Left` | Horizontal alignment (`Left`, `Right`, `Center`) | |
| 254 | +| `MinWidth` | `int?` | `4` | Column won't shrink below this width | |
| 255 | +| `MaxWidth` | `int?` | `null` | Content exceeding this is truncated with ellipsis | |
| 256 | +| `TruncateMode` | `TruncateMode` | `End` | Where to place ellipsis when truncating | |
| 257 | +| `HeaderColor` | `string?` | `null` | ANSI color code for header text | |
| 258 | + |
| 259 | +### TruncateMode (Ellipsis Placement) |
| 260 | + |
| 261 | +| Mode | Result | Use case | |
| 262 | +|------|--------|----------| |
| 263 | +| `TruncateMode.End` | `"long text..."` | Default — shows beginning | |
| 264 | +| `TruncateMode.Start` | `"...long text"` | File paths — shows the meaningful end | |
| 265 | +| `TruncateMode.Middle` | `"long...text"` | Shows both start and end | |
| 266 | + |
| 267 | +#### Example: All three modes side-by-side |
| 268 | + |
| 269 | +```csharp |
| 270 | +string longText = "This-is-a-very-long-text-that-will-be-truncated-differently"; |
| 271 | + |
| 272 | +terminal.WriteTable(t => t |
| 273 | + .AddColumn(new TableColumn("Mode") { MaxWidth = 8 }) |
| 274 | + .AddColumn(new TableColumn("End (default)") { MaxWidth = 25, TruncateMode = TruncateMode.End }) |
| 275 | + .AddColumn(new TableColumn("Start") { MaxWidth = 25, TruncateMode = TruncateMode.Start }) |
| 276 | + .AddColumn(new TableColumn("Middle") { MaxWidth = 25, TruncateMode = TruncateMode.Middle }) |
| 277 | + .AddRow("Result", longText, longText, longText) |
| 278 | + .Border(BorderStyle.Rounded)); |
| 279 | +``` |
| 280 | + |
| 281 | +#### Example: File paths with TruncateMode.Start |
| 282 | + |
| 283 | +```csharp |
| 284 | +terminal.WriteTable(t => t |
| 285 | + .AddColumn("Repository") |
| 286 | + .AddColumn(new TableColumn("Worktree Path") { TruncateMode = TruncateMode.Start }) |
| 287 | + .AddColumn("Branch") |
| 288 | + .AddRow("timewarp-nuru", |
| 289 | + "/home/user/worktrees/github.com/TimeWarpEngineering/timewarp-nuru/feature-branch-name", |
| 290 | + "feature-xyz") |
| 291 | + .Border(BorderStyle.Rounded)); |
| 292 | +// Path column shows: "...timewarp-nuru/feature-branch-name" |
| 293 | +``` |
| 294 | + |
| 295 | +#### Example: Column with MinWidth constraint |
| 296 | + |
| 297 | +```csharp |
| 298 | +terminal.WriteTable(t => t |
| 299 | + .AddColumn("ID") |
| 300 | + .AddColumn(new TableColumn("Description") { MinWidth = 20 }) |
| 301 | + .AddRow("1", "This is a long description that would normally be truncated heavily")); |
| 302 | +``` |
| 303 | + |
| 304 | +### Table Shrink/Expand Behavior |
| 305 | + |
| 306 | +- **Shrink** (default: `true`): Proportionally reduces column widths to fit terminal. Wider columns shrink more aggressively. Respects `MinWidth` per column. |
| 307 | +- **Expand**: Distributes extra terminal width evenly across columns. |
| 308 | +- Disable shrinking with builder `.Shrink(false)` to allow horizontal overflow. |
| 309 | + |
| 310 | +**BorderStyle:** `Rounded`, `Square`, `Doubled`, `Heavy`, `None` |
| 311 | + |
| 312 | +**Alignment:** `Left`, `Right`, `Center` |
| 313 | + |
| 314 | +## AnsiStringUtils |
| 315 | + |
| 316 | +Static utility class for ANSI-aware string operations. Handles CSI sequences and OSC 8 hyperlinks. |
| 317 | + |
| 318 | +```csharp |
| 319 | +AnsiStringUtils.StripAnsiCodes("\x1b[32mGreen\x1b[0m"); // "Green" |
| 320 | +AnsiStringUtils.GetVisibleLength("\x1b[32mGreen\x1b[0m"); // 5 |
| 321 | +AnsiStringUtils.GetVisibleLength("📍 Location"); // 11 (uses UnicodeWidth) |
| 322 | +AnsiStringUtils.PadRightVisible("\x1b[32mHi\x1b[0m", 10); // pads to 10 visible chars |
| 323 | +AnsiStringUtils.PadLeftVisible("\x1b[32mHi\x1b[0m", 10); // left-pads to 10 |
| 324 | +AnsiStringUtils.CenterVisible("\x1b[32mHi\x1b[0m", 10); // centers to 10 |
| 325 | +AnsiStringUtils.WrapText("long text...", 40); // word-wrap, grapheme-aware |
| 326 | +``` |
| 327 | + |
| 328 | +## Common Pitfalls |
| 329 | + |
| 330 | +1. **Don't use `new Table()`, `new Panel()`, `new Rule()`** - Constructors are internal. Use builders or `Action<XxxBuilder>` extension methods. |
| 331 | +2. **Don't mix Console and Terminal** - Pick one, stick with it |
| 332 | +3. **Always restore Terminal.Instance in tests** - Use try/finally or using pattern |
| 333 | +4. **Check SupportsColor/SupportsHyperlinks** before using those features in production |
| 334 | +5. **Use `UnicodeWidth.GetTextWidth()` not `.Length`** for terminal column calculations — `.Length` counts UTF-16 code units, not display columns |
0 commit comments