@pablozaiden/terminatui 0.2.0 → 0.3.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 (181) hide show
  1. package/README.md +64 -43
  2. package/package.json +11 -8
  3. package/src/__tests__/application.test.ts +87 -68
  4. package/src/__tests__/buildCliCommand.test.ts +99 -119
  5. package/src/__tests__/builtins.test.ts +27 -75
  6. package/src/__tests__/command.test.ts +100 -131
  7. package/src/__tests__/configOnChange.test.ts +63 -0
  8. package/src/__tests__/context.test.ts +1 -26
  9. package/src/__tests__/helpCore.test.ts +227 -0
  10. package/src/__tests__/parser.test.ts +98 -244
  11. package/src/__tests__/registry.test.ts +33 -160
  12. package/src/__tests__/schemaToFields.test.ts +75 -158
  13. package/src/builtins/help.ts +12 -4
  14. package/src/builtins/settings.ts +18 -32
  15. package/src/builtins/version.ts +3 -3
  16. package/src/cli/output/colors.ts +1 -1
  17. package/src/cli/parser.ts +26 -95
  18. package/src/core/application.ts +192 -110
  19. package/src/core/command.ts +26 -9
  20. package/src/core/context.ts +31 -20
  21. package/src/core/help.ts +24 -18
  22. package/src/core/knownCommands.ts +13 -0
  23. package/src/core/logger.ts +39 -42
  24. package/src/core/registry.ts +5 -12
  25. package/src/index.ts +22 -137
  26. package/src/tui/TuiApplication.tsx +63 -120
  27. package/src/tui/TuiRoot.tsx +135 -0
  28. package/src/tui/adapters/factory.ts +19 -0
  29. package/src/tui/adapters/ink/InkRenderer.tsx +139 -0
  30. package/src/tui/adapters/ink/components/Button.tsx +12 -0
  31. package/src/tui/adapters/ink/components/Code.tsx +6 -0
  32. package/src/tui/adapters/ink/components/CodeHighlight.tsx +6 -0
  33. package/src/tui/adapters/ink/components/Container.tsx +5 -0
  34. package/src/tui/adapters/ink/components/Field.tsx +12 -0
  35. package/src/tui/adapters/ink/components/Label.tsx +24 -0
  36. package/src/tui/adapters/ink/components/MenuButton.tsx +12 -0
  37. package/src/tui/adapters/ink/components/MenuItem.tsx +17 -0
  38. package/src/tui/adapters/ink/components/Overlay.tsx +5 -0
  39. package/src/tui/adapters/ink/components/Panel.tsx +15 -0
  40. package/src/tui/adapters/ink/components/ScrollView.tsx +5 -0
  41. package/src/tui/adapters/ink/components/Select.tsx +44 -0
  42. package/src/tui/adapters/ink/components/Spacer.tsx +15 -0
  43. package/src/tui/adapters/ink/components/Spinner.tsx +5 -0
  44. package/src/tui/adapters/ink/components/TextInput.tsx +22 -0
  45. package/src/tui/adapters/ink/components/Value.tsx +7 -0
  46. package/src/tui/adapters/ink/keyboard.ts +97 -0
  47. package/src/tui/adapters/ink/utils.ts +16 -0
  48. package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +119 -0
  49. package/src/tui/adapters/opentui/components/Button.tsx +13 -0
  50. package/src/tui/adapters/opentui/components/Code.tsx +12 -0
  51. package/src/tui/adapters/opentui/components/CodeHighlight.tsx +24 -0
  52. package/src/tui/adapters/opentui/components/Container.tsx +56 -0
  53. package/src/tui/adapters/opentui/components/Field.tsx +18 -0
  54. package/src/tui/adapters/opentui/components/Label.tsx +15 -0
  55. package/src/tui/adapters/opentui/components/MenuButton.tsx +14 -0
  56. package/src/tui/adapters/opentui/components/MenuItem.tsx +29 -0
  57. package/src/tui/adapters/opentui/components/Overlay.tsx +21 -0
  58. package/src/tui/adapters/opentui/components/Panel.tsx +78 -0
  59. package/src/tui/adapters/opentui/components/ScrollView.tsx +85 -0
  60. package/src/tui/adapters/opentui/components/Select.tsx +59 -0
  61. package/src/tui/adapters/opentui/components/Spacer.tsx +5 -0
  62. package/src/tui/adapters/opentui/components/Spinner.tsx +12 -0
  63. package/src/tui/adapters/opentui/components/TextInput.tsx +13 -0
  64. package/src/tui/adapters/opentui/components/Value.tsx +13 -0
  65. package/src/tui/{hooks → adapters/opentui/hooks}/useSpinner.ts +2 -11
  66. package/src/tui/adapters/opentui/keyboard.ts +61 -0
  67. package/src/tui/adapters/types.ts +71 -0
  68. package/src/tui/components/ActionButton.tsx +0 -36
  69. package/src/tui/components/CommandSelector.tsx +45 -92
  70. package/src/tui/components/ConfigForm.tsx +68 -42
  71. package/src/tui/components/FieldRow.tsx +0 -30
  72. package/src/tui/components/Header.tsx +14 -13
  73. package/src/tui/components/JsonHighlight.tsx +10 -17
  74. package/src/tui/components/ModalBase.tsx +38 -0
  75. package/src/tui/components/ResultsPanel.tsx +27 -36
  76. package/src/tui/components/StatusBar.tsx +24 -39
  77. package/src/tui/components/logColors.ts +12 -0
  78. package/src/tui/context/ClipboardContext.tsx +87 -0
  79. package/src/tui/context/ExecutorContext.tsx +139 -0
  80. package/src/tui/context/KeyboardContext.tsx +85 -71
  81. package/src/tui/context/LogsContext.tsx +35 -0
  82. package/src/tui/context/NavigationContext.tsx +194 -0
  83. package/src/tui/context/RendererContext.tsx +20 -0
  84. package/src/tui/context/TuiAppContext.tsx +58 -0
  85. package/src/tui/hooks/useActiveKeyHandler.ts +75 -0
  86. package/src/tui/hooks/useBackHandler.ts +34 -0
  87. package/src/tui/hooks/useClipboard.ts +40 -25
  88. package/src/tui/hooks/useClipboardProvider.ts +42 -0
  89. package/src/tui/hooks/useGlobalKeyHandler.ts +54 -0
  90. package/src/tui/modals/CliModal.tsx +82 -0
  91. package/src/tui/modals/EditorModal.tsx +207 -0
  92. package/src/tui/modals/LogsModal.tsx +98 -0
  93. package/src/tui/registry.ts +102 -0
  94. package/src/tui/screens/CommandSelectScreen.tsx +162 -0
  95. package/src/tui/screens/ConfigScreen.tsx +165 -0
  96. package/src/tui/screens/ErrorScreen.tsx +58 -0
  97. package/src/tui/screens/ResultsScreen.tsx +68 -0
  98. package/src/tui/screens/RunningScreen.tsx +72 -0
  99. package/src/tui/screens/ScreenBase.ts +6 -0
  100. package/src/tui/semantic/Button.tsx +7 -0
  101. package/src/tui/semantic/Code.tsx +7 -0
  102. package/src/tui/semantic/CodeHighlight.tsx +7 -0
  103. package/src/tui/semantic/Container.tsx +7 -0
  104. package/src/tui/semantic/Field.tsx +7 -0
  105. package/src/tui/semantic/Label.tsx +7 -0
  106. package/src/tui/semantic/MenuButton.tsx +7 -0
  107. package/src/tui/semantic/MenuItem.tsx +7 -0
  108. package/src/tui/semantic/Overlay.tsx +7 -0
  109. package/src/tui/semantic/Panel.tsx +7 -0
  110. package/src/tui/semantic/ScrollView.tsx +9 -0
  111. package/src/tui/semantic/Select.tsx +7 -0
  112. package/src/tui/semantic/Spacer.tsx +7 -0
  113. package/src/tui/semantic/Spinner.tsx +7 -0
  114. package/src/tui/semantic/TextInput.tsx +7 -0
  115. package/src/tui/semantic/Value.tsx +7 -0
  116. package/src/tui/semantic/types.ts +195 -0
  117. package/src/tui/theme.ts +25 -14
  118. package/src/tui/utils/buildCliCommand.ts +1 -0
  119. package/src/tui/utils/getEnumKeys.ts +3 -0
  120. package/src/tui/utils/parameterPersistence.ts +1 -0
  121. package/src/types/command.ts +0 -60
  122. package/.devcontainer/devcontainer.json +0 -19
  123. package/.devcontainer/install-prerequisites.sh +0 -49
  124. package/.github/workflows/copilot-setup-steps.yml +0 -32
  125. package/.github/workflows/pull-request.yml +0 -27
  126. package/.github/workflows/release-npm-package.yml +0 -81
  127. package/AGENTS.md +0 -31
  128. package/bun.lock +0 -236
  129. package/examples/tui-app/commands/config/app/get.ts +0 -66
  130. package/examples/tui-app/commands/config/app/index.ts +0 -27
  131. package/examples/tui-app/commands/config/app/set.ts +0 -86
  132. package/examples/tui-app/commands/config/index.ts +0 -32
  133. package/examples/tui-app/commands/config/user/get.ts +0 -65
  134. package/examples/tui-app/commands/config/user/index.ts +0 -27
  135. package/examples/tui-app/commands/config/user/set.ts +0 -61
  136. package/examples/tui-app/commands/greet.ts +0 -76
  137. package/examples/tui-app/commands/index.ts +0 -4
  138. package/examples/tui-app/commands/math.ts +0 -115
  139. package/examples/tui-app/commands/status.ts +0 -77
  140. package/examples/tui-app/index.ts +0 -35
  141. package/guides/01-hello-world.md +0 -96
  142. package/guides/02-adding-options.md +0 -103
  143. package/guides/03-multiple-commands.md +0 -163
  144. package/guides/04-subcommands.md +0 -206
  145. package/guides/05-interactive-tui.md +0 -194
  146. package/guides/06-config-validation.md +0 -264
  147. package/guides/07-async-cancellation.md +0 -336
  148. package/guides/08-complete-application.md +0 -537
  149. package/guides/README.md +0 -74
  150. package/src/__tests__/colors.test.ts +0 -127
  151. package/src/__tests__/commandClass.test.ts +0 -130
  152. package/src/__tests__/help.test.ts +0 -412
  153. package/src/__tests__/registryNew.test.ts +0 -160
  154. package/src/__tests__/table.test.ts +0 -146
  155. package/src/__tests__/tui.test.ts +0 -26
  156. package/src/builtins/index.ts +0 -4
  157. package/src/cli/help.ts +0 -174
  158. package/src/cli/index.ts +0 -3
  159. package/src/cli/output/index.ts +0 -2
  160. package/src/cli/output/table.ts +0 -141
  161. package/src/commands/help.ts +0 -50
  162. package/src/commands/index.ts +0 -1
  163. package/src/components/index.ts +0 -147
  164. package/src/core/index.ts +0 -15
  165. package/src/hooks/index.ts +0 -131
  166. package/src/registry/commandRegistry.ts +0 -77
  167. package/src/registry/index.ts +0 -1
  168. package/src/tui/TuiApp.tsx +0 -619
  169. package/src/tui/app.ts +0 -29
  170. package/src/tui/components/CliModal.tsx +0 -81
  171. package/src/tui/components/EditorModal.tsx +0 -177
  172. package/src/tui/components/LogsPanel.tsx +0 -86
  173. package/src/tui/components/index.ts +0 -13
  174. package/src/tui/context/index.ts +0 -7
  175. package/src/tui/hooks/index.ts +0 -35
  176. package/src/tui/hooks/useKeyboardHandler.ts +0 -91
  177. package/src/tui/hooks/useLogStream.ts +0 -96
  178. package/src/tui/index.ts +0 -65
  179. package/src/tui/utils/index.ts +0 -13
  180. package/src/types/index.ts +0 -1
  181. package/tsconfig.json +0 -25
@@ -1,8 +1,11 @@
1
1
  import { useRef, useEffect, type ReactNode } from "react";
2
- import type { ScrollBoxRenderable } from "@opentui/core";
3
- import { Theme } from "../theme.ts";
4
- import { FieldRow } from "./FieldRow.tsx";
5
- import { useKeyboardHandler, KeyboardPriority } from "../hooks/useKeyboardHandler.ts";
2
+ import { Field } from "../semantic/Field.tsx";
3
+ import { MenuButton } from "../semantic/MenuButton.tsx";
4
+ import { Panel } from "../semantic/Panel.tsx";
5
+ import { ScrollView, type ScrollViewRef } from "../semantic/ScrollView.tsx";
6
+ import { Container } from "../semantic/Container.tsx";
7
+ import { useActiveKeyHandler } from "../hooks/useActiveKeyHandler.ts";
8
+ import type { KeyboardEvent } from "../adapters/types.ts";
6
9
  import type { FieldConfig } from "./types.ts";
7
10
 
8
11
  interface ConfigFormProps {
@@ -26,6 +29,10 @@ interface ConfigFormProps {
26
29
  getDisplayValue?: (key: string, value: unknown, type: string) => string;
27
30
  /** The action button component */
28
31
  actionButton: ReactNode;
32
+ /** Optional additional buttons rendered before the main action button */
33
+ additionalButtons?: { label: string; onPress: () => void }[];
34
+ /** Optional handler for additional keys (called before default handling) */
35
+ onKeyDown?: (event: KeyboardEvent) => boolean;
29
36
  }
30
37
 
31
38
  /**
@@ -56,72 +63,80 @@ export function ConfigForm({
56
63
  onAction,
57
64
  getDisplayValue = defaultGetDisplayValue,
58
65
  actionButton,
66
+ additionalButtons = [],
67
+ onKeyDown,
59
68
  }: ConfigFormProps) {
60
- const borderColor = focused ? Theme.borderFocused : Theme.border;
61
- const scrollboxRef = useRef<ScrollBoxRenderable>(null);
62
- const totalFields = fieldConfigs.length + 1; // +1 for action button
69
+ const scrollViewRef = useRef<ScrollViewRef | null>(null);
70
+ const totalItems = fieldConfigs.length + additionalButtons.length + 1; // fields + additional buttons + action button
63
71
 
64
72
  // Auto-scroll to keep selected item visible
65
73
  useEffect(() => {
66
- if (scrollboxRef.current) {
67
- scrollboxRef.current.scrollTo(selectedIndex);
68
- }
74
+ scrollViewRef.current?.scrollToIndex(selectedIndex);
69
75
  }, [selectedIndex]);
70
76
 
71
- // Handle keyboard events at Focused priority (only when focused)
72
- useKeyboardHandler(
73
- (event) => {
74
- const { key } = event;
77
+ // Handle keyboard events (only when focused)
78
+ useActiveKeyHandler(
79
+ (event: KeyboardEvent) => {
80
+ // Let parent handle first if provided
81
+ if (onKeyDown?.(event)) {
82
+ return true;
83
+ }
84
+
85
+ const key = event;
75
86
 
76
87
  // Arrow key navigation
77
88
  if (key.name === "down") {
78
- const newIndex = Math.min(selectedIndex + 1, totalFields - 1);
89
+ const newIndex = Math.min(selectedIndex + 1, totalItems - 1);
79
90
  onSelectionChange(newIndex);
80
- event.stopPropagation();
81
- return;
91
+ return true;
82
92
  }
83
93
 
84
94
  if (key.name === "up") {
85
95
  const newIndex = Math.max(selectedIndex - 1, 0);
86
96
  onSelectionChange(newIndex);
87
- event.stopPropagation();
88
- return;
97
+ return true;
89
98
  }
90
99
 
91
- // Enter to edit field or run action
100
+ // Enter to edit field, press additional button, or run action
92
101
  if (key.name === "return" || key.name === "enter") {
93
- if (selectedIndex === fieldConfigs.length) {
94
- onAction();
95
- } else {
102
+ if (selectedIndex < fieldConfigs.length) {
103
+ // It's a field
96
104
  const fieldConfig = fieldConfigs[selectedIndex];
97
105
  if (fieldConfig) {
98
106
  onEditField(fieldConfig.key);
99
107
  }
108
+ } else if (selectedIndex < fieldConfigs.length + additionalButtons.length) {
109
+ // It's an additional button
110
+ const buttonIndex = selectedIndex - fieldConfigs.length;
111
+ additionalButtons[buttonIndex]?.onPress();
112
+ } else {
113
+ // It's the main action button
114
+ onAction();
100
115
  }
101
- event.stopPropagation();
102
- return;
116
+ return true;
103
117
  }
118
+
119
+ return false;
104
120
  },
105
- KeyboardPriority.Focused,
106
121
  { enabled: focused }
107
122
  );
108
123
 
109
124
  return (
110
- <box
111
- flexDirection="column"
112
- border={true}
113
- borderStyle="rounded"
114
- borderColor={borderColor}
125
+ <Panel
115
126
  title={title}
116
- flexGrow={1}
127
+ focused={focused}
128
+ flex={1}
117
129
  padding={1}
130
+ flexDirection="column"
118
131
  >
119
- <scrollbox
120
- ref={scrollboxRef}
121
- scrollY={true}
122
- flexGrow={1}
132
+ <ScrollView
133
+ axis="vertical"
134
+ flex={1}
135
+ scrollRef={(ref) => {
136
+ scrollViewRef.current = ref;
137
+ }}
123
138
  >
124
- <box flexDirection="column" gap={0}>
139
+ <Container flexDirection="column" gap={0}>
125
140
  {fieldConfigs.map((field, idx) => {
126
141
  const isSelected = idx === selectedIndex;
127
142
  const displayValue = getDisplayValue(
@@ -131,18 +146,29 @@ export function ConfigForm({
131
146
  );
132
147
 
133
148
  return (
134
- <FieldRow
149
+ <Field
135
150
  key={field.key}
136
151
  label={field.label}
137
152
  value={displayValue}
138
- isSelected={isSelected}
153
+ selected={isSelected}
154
+ />
155
+ );
156
+ })}
157
+
158
+ {additionalButtons.map((btn, idx) => {
159
+ const buttonSelectedIndex = fieldConfigs.length + idx;
160
+ return (
161
+ <MenuButton
162
+ key={btn.label}
163
+ label={btn.label}
164
+ selected={selectedIndex === buttonSelectedIndex}
139
165
  />
140
166
  );
141
167
  })}
142
168
 
143
169
  {actionButton}
144
- </box>
145
- </scrollbox>
146
- </box>
170
+ </Container>
171
+ </ScrollView>
172
+ </Panel>
147
173
  );
148
174
  }
@@ -1,30 +0,0 @@
1
- import { Theme } from "../theme.ts";
2
-
3
- interface FieldRowProps {
4
- /** Field label */
5
- label: string;
6
- /** Field value to display */
7
- value: string;
8
- /** Whether this row is selected */
9
- isSelected: boolean;
10
- }
11
-
12
- /**
13
- * A single row in a config form displaying a field label and value.
14
- */
15
- export function FieldRow({ label, value, isSelected }: FieldRowProps) {
16
- const prefix = isSelected ? "► " : " ";
17
- const labelColor = isSelected ? Theme.borderFocused : Theme.label;
18
- const valueColor = isSelected ? Theme.value : Theme.statusText;
19
-
20
- return (
21
- <box flexDirection="row" gap={1}>
22
- <text fg={labelColor}>
23
- {prefix}{label}:
24
- </text>
25
- <text fg={valueColor}>
26
- {value}
27
- </text>
28
- </box>
29
- );
30
- }
@@ -1,4 +1,6 @@
1
- import { Theme } from "../theme.ts";
1
+ import { Container } from "../semantic/Container.tsx";
2
+ import { Label } from "../semantic/Label.tsx";
3
+ import { Spacer } from "../semantic/Spacer.tsx";
2
4
 
3
5
  interface HeaderProps {
4
6
  /** Application name */
@@ -13,19 +15,18 @@ interface HeaderProps {
13
15
  * Application header with name, version, and optional breadcrumb.
14
16
  */
15
17
  export function Header({ name, version, breadcrumb }: HeaderProps) {
16
- const breadcrumbStr = breadcrumb?.length
17
- ? ` › ${breadcrumb.join(" › ")}`
18
- : "";
18
+ const breadcrumbStr = breadcrumb?.length ? ` › ${breadcrumb.join(" › ")}` : "";
19
19
 
20
20
  return (
21
- <box flexDirection="row" justifyContent="space-between" marginBottom={1}>
22
- <text fg={Theme.header}>
23
- <strong>{name}</strong>
24
- {breadcrumbStr}
25
- </text>
26
- <text fg={Theme.label}>
27
- v{version}
28
- </text>
29
- </box>
21
+ <Container flexDirection="column" noShrink>
22
+ <Container flexDirection="row" justifyContent="space-between">
23
+ <Label color="mutedText" bold>
24
+ {name}
25
+ {breadcrumbStr}
26
+ </Label>
27
+ <Label color="mutedText">v{version}</Label>
28
+ </Container>
29
+ <Spacer size={1} />
30
+ </Container>
30
31
  );
31
32
  }
@@ -1,20 +1,14 @@
1
- import { Theme } from "../theme.ts";
1
+ import { Container } from "../semantic/Container.tsx";
2
+ import { CodeHighlight } from "../semantic/CodeHighlight.tsx";
3
+ import type { CodeTokenType } from "../semantic/types.ts";
2
4
 
3
5
  /**
4
6
  * JSON syntax highlighting types and colors
5
7
  */
6
- type JsonTokenType = "key" | "string" | "number" | "boolean" | "null" | "punctuation";
8
+ type JsonTokenType = Exclude<CodeTokenType, "unknown">;
7
9
  type JsonToken = { type: JsonTokenType; value: string };
8
10
  type JsonLineTokens = JsonToken[];
9
11
 
10
- const TOKEN_COLORS: Record<JsonTokenType, string> = {
11
- key: "#61afef", // blue
12
- string: "#98c379", // green
13
- number: "#d19a66", // orange
14
- boolean: "#c678dd", // purple
15
- null: "#c678dd", // purple
16
- punctuation: Theme.label,
17
- };
18
12
 
19
13
  function tokenizeJson(value: unknown, indent = 0): JsonLineTokens[] {
20
14
  const pad = " ".repeat(indent);
@@ -115,14 +109,13 @@ export interface JsonHighlightProps {
115
109
  export function JsonHighlight({ value }: JsonHighlightProps) {
116
110
  const lines = tokenizeJson(value);
117
111
  return (
118
- <box flexDirection="column">
112
+ <Container flexDirection="column" gap={0}>
119
113
  {lines.map((tokens, lineIdx) => (
120
- <text key={`json-${lineIdx}`}>
121
- {tokens.map((token, tokenIdx) => (
122
- <span key={tokenIdx} fg={TOKEN_COLORS[token.type]}>{token.value}</span>
123
- ))}
124
- </text>
114
+ <CodeHighlight
115
+ key={`json-${lineIdx}`}
116
+ tokens={tokens.map((token) => ({ type: token.type, value: token.value }))}
117
+ />
125
118
  ))}
126
- </box>
119
+ </Container>
127
120
  );
128
121
  }
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from "react";
2
+ import { Panel } from "../semantic/Panel.tsx";
3
+ import { Container } from "../semantic/Container.tsx";
4
+ import { Overlay } from "../semantic/Overlay.tsx";
5
+
6
+ type Dim = number | `${number}%` | "auto";
7
+
8
+ interface ModalBaseProps {
9
+ title?: string;
10
+ width?: Dim;
11
+ height?: Dim;
12
+ top?: Dim;
13
+ left?: Dim;
14
+ right?: Dim;
15
+ bottom?: Dim;
16
+ children: ReactNode;
17
+ }
18
+
19
+ export function ModalBase({
20
+ title,
21
+ width = "80%",
22
+ height = "auto",
23
+ top = 4,
24
+ left = 4,
25
+ right,
26
+ bottom,
27
+ children,
28
+ }: ModalBaseProps) {
29
+ return (
30
+ <Overlay zIndex={20} top={top} left={left} right={right} bottom={bottom} width={width} height={height}>
31
+ <Panel title={title} border={true} flexDirection="column" flex={1} padding={1} surface="overlay">
32
+ <Container flexDirection="column" gap={1} flex={1}>
33
+ {children}
34
+ </Container>
35
+ </Panel>
36
+ </Overlay>
37
+ );
38
+ }
@@ -1,6 +1,10 @@
1
1
  import type { ReactNode } from "react";
2
- import { Theme } from "../theme.ts";
3
2
  import type { CommandResult } from "../../core/command.ts";
3
+ import { Container } from "../semantic/Container.tsx";
4
+ import { Panel } from "../semantic/Panel.tsx";
5
+ import { ScrollView } from "../semantic/ScrollView.tsx";
6
+ import { Label } from "../semantic/Label.tsx";
7
+ import { Value } from "../semantic/Value.tsx";
4
8
 
5
9
  interface ResultsPanelProps {
6
10
  /** The result to display */
@@ -22,21 +26,18 @@ export function ResultsPanel({
22
26
  focused,
23
27
  renderResult,
24
28
  }: ResultsPanelProps) {
25
- const borderColor = focused ? Theme.borderFocused : Theme.border;
26
29
 
27
30
  // Determine content to display
28
31
  let content: ReactNode;
29
32
 
30
33
  if (error) {
31
34
  content = (
32
- <box flexDirection="column" gap={1}>
33
- <text fg={Theme.error}>
34
- <strong>Error</strong>
35
- </text>
36
- <text fg={Theme.error}>
37
- {error.message}
38
- </text>
39
- </box>
35
+ <Container flexDirection="column" gap={1}>
36
+ <Label color="error" bold>
37
+ Error
38
+ </Label>
39
+ <Label color="error">{error.message}</Label>
40
+ </Container>
40
41
  );
41
42
  } else if (result) {
42
43
  if (renderResult) {
@@ -44,50 +45,40 @@ export function ResultsPanel({
44
45
 
45
46
  if (typeof customContent === "string" || typeof customContent === "number" || typeof customContent === "boolean") {
46
47
  // Wrap primitive results so the renderer gets a text node
47
- content = (
48
- <text fg={Theme.value}>
49
- {String(customContent)}
50
- </text>
51
- );
48
+ content = <Value>{String(customContent)}</Value>;
52
49
  } else {
53
50
  content = customContent as ReactNode;
54
51
  }
55
52
  } else {
56
53
  // Default JSON display
57
54
  content = (
58
- <box flexDirection="column" gap={1}>
55
+ <Container flexDirection="column" gap={1}>
59
56
  {result.message && (
60
- <text fg={result.success ? Theme.success : Theme.error}>
61
- {result.message}
62
- </text>
57
+ <Label color={result.success ? "success" : "error"}>{result.message}</Label>
63
58
  )}
64
59
  {result.data !== undefined && result.data !== null && (
65
- <text fg={Theme.value}>
66
- {JSON.stringify(result.data, null, 2)}
67
- </text>
60
+ <Value>{JSON.stringify(result.data, null, 2)}</Value>
68
61
  )}
69
- </box>
62
+ </Container>
70
63
  );
71
64
  }
72
65
  } else {
73
- content = (
74
- <text fg={Theme.label}>No results yet...</text>
75
- );
66
+ content = <Label color="mutedText">No results yet...</Label>;
76
67
  }
77
68
 
78
69
  return (
79
- <box
80
- flexDirection="column"
81
- border={true}
82
- borderStyle="rounded"
83
- borderColor={borderColor}
70
+ <Panel
84
71
  title="Results"
72
+ focused={focused}
73
+ flex={1}
85
74
  padding={1}
86
- flexGrow={1}
75
+ flexDirection="column"
87
76
  >
88
- <scrollbox scrollY={true} flexGrow={1} focused={focused}>
89
- {content}
90
- </scrollbox>
91
- </box>
77
+ <ScrollView axis="vertical" flex={1} focused={focused}>
78
+ <Container flexDirection="column">
79
+ {content}
80
+ </Container>
81
+ </ScrollView>
82
+ </Panel>
92
83
  );
93
84
  }
@@ -1,5 +1,7 @@
1
- import { Theme } from "../theme.ts";
2
- import { useSpinner } from "../hooks/useSpinner.ts";
1
+ import { Label } from "../semantic/Label.tsx";
2
+ import { Spinner } from "../semantic/Spinner.tsx";
3
+ import { Panel } from "../semantic/Panel.tsx";
4
+ import { Container } from "../semantic/Container.tsx";
3
5
 
4
6
  interface StatusBarProps {
5
7
  /** Status message to display */
@@ -15,45 +17,28 @@ interface StatusBarProps {
15
17
  /**
16
18
  * Status bar showing current status, spinner, and keyboard shortcuts.
17
19
  */
18
- export function StatusBar({
19
- status,
20
- isRunning = false,
20
+ export function StatusBar({
21
+ status,
22
+ isRunning = false,
21
23
  showShortcuts = true,
22
- shortcuts = "L Logs • C CLI • Tab Switch • Ctrl+Y Copy • Esc Back"
24
+ shortcuts = "L Logs • C CLI • Tab Switch • Ctrl+Y Copy • Esc Back",
23
25
  }: StatusBarProps) {
24
- const { frame } = useSpinner(isRunning);
25
- const spinner = isRunning ? `${frame} ` : "";
26
-
27
26
  return (
28
- <box
29
- flexDirection="column"
30
- gap={0}
31
- border={true}
32
- borderStyle="rounded"
33
- borderColor={isRunning ? "#4ade80" : Theme.border}
34
- flexShrink={0}
35
- >
36
- {/* Main status with spinner */}
37
- <box
38
- flexDirection="row"
39
- justifyContent="space-between"
40
- backgroundColor={isRunning ? "#1a1a2e" : undefined}
41
- paddingLeft={1}
42
- paddingRight={1}
43
- >
44
- <text fg={isRunning ? "#4ade80" : Theme.statusText}>
45
- {isRunning ? <strong>{spinner}{status}</strong> : <>{spinner}{status}</>}
46
- </text>
47
- </box>
48
-
49
- {/* Keyboard shortcuts */}
50
- {showShortcuts && (
51
- <box paddingLeft={1} paddingRight={1}>
52
- <text fg={Theme.label}>
53
- {shortcuts}
54
- </text>
55
- </box>
56
- )}
57
- </box>
27
+ <Panel dense border={true} flexDirection="column" gap={0} height={showShortcuts ? 4 : 2}>
28
+ <Container flexDirection="row" justifyContent="space-between" padding={{ left: 1, right: 1 }}>
29
+ <Container flexDirection="row">
30
+ <Spinner active={isRunning} />
31
+ <Label color="success" bold>
32
+ {status}
33
+ </Label>
34
+ </Container>
35
+ </Container>
36
+
37
+ {showShortcuts ? (
38
+ <Container padding={{ left: 1, right: 1 }}>
39
+ <Label color="mutedText">{shortcuts}</Label>
40
+ </Container>
41
+ ) : null}
42
+ </Panel>
58
43
  );
59
44
  }
@@ -0,0 +1,12 @@
1
+ import { LogLevel } from "../../core/logger";
2
+
3
+ // Shared colors for log levels used across debug views.
4
+ export const LogColors: Record<LogLevel, string> = {
5
+ [LogLevel.silly]: "#8c8c8c",
6
+ [LogLevel.trace]: "#6dd6ff",
7
+ [LogLevel.debug]: "#7bdcb5",
8
+ [LogLevel.info]: "#d6dde6",
9
+ [LogLevel.warn]: "#f5c542",
10
+ [LogLevel.error]: "#f78888",
11
+ [LogLevel.fatal]: "#ff5c8d",
12
+ };
@@ -0,0 +1,87 @@
1
+ import { createContext, useContext, useRef, useCallback, type ReactNode } from "react";
2
+
3
+ /**
4
+ * Clipboard content that can be provided by a screen or modal.
5
+ */
6
+ export interface ClipboardContent {
7
+ content: string;
8
+ label: string;
9
+ }
10
+
11
+ /**
12
+ * Provider function that returns clipboard content or null.
13
+ */
14
+ export type ClipboardProvider = () => ClipboardContent | null;
15
+
16
+ interface ClipboardContextValue {
17
+ /**
18
+ * Register a clipboard provider. Returns an unregister function.
19
+ * Providers are stacked - the most recently registered provider is checked first.
20
+ */
21
+ register: (id: string, provider: ClipboardProvider) => () => void;
22
+
23
+ /**
24
+ * Get clipboard content from the topmost provider that returns content.
25
+ */
26
+ getContent: () => ClipboardContent | null;
27
+ }
28
+
29
+ const ClipboardContext = createContext<ClipboardContextValue | null>(null);
30
+
31
+ interface ClipboardProviderProps {
32
+ children: ReactNode;
33
+ }
34
+
35
+ /**
36
+ * Provider that manages clipboard content providers from screens and modals.
37
+ * Providers are stacked - modals register on top of screens, so modal content
38
+ * takes precedence when copying.
39
+ */
40
+ export function ClipboardProviderComponent({ children }: ClipboardProviderProps) {
41
+ const providersRef = useRef<Map<string, ClipboardProvider>>(new Map());
42
+ const orderRef = useRef<string[]>([]);
43
+
44
+ const register = useCallback((id: string, provider: ClipboardProvider) => {
45
+ providersRef.current.set(id, provider);
46
+ // Add to end (most recent)
47
+ orderRef.current = orderRef.current.filter((i) => i !== id);
48
+ orderRef.current.push(id);
49
+
50
+ return () => {
51
+ providersRef.current.delete(id);
52
+ orderRef.current = orderRef.current.filter((i) => i !== id);
53
+ };
54
+ }, []);
55
+
56
+ const getContent = useCallback((): ClipboardContent | null => {
57
+ // Check providers in reverse order (most recent first)
58
+ for (let i = orderRef.current.length - 1; i >= 0; i--) {
59
+ const id = orderRef.current[i];
60
+ const provider = providersRef.current.get(id!);
61
+ if (provider) {
62
+ const content = provider();
63
+ if (content) {
64
+ return content;
65
+ }
66
+ }
67
+ }
68
+ return null;
69
+ }, []);
70
+
71
+ return (
72
+ <ClipboardContext.Provider value={{ register, getContent }}>
73
+ {children}
74
+ </ClipboardContext.Provider>
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Access the clipboard context.
80
+ */
81
+ export function useClipboardContext(): ClipboardContextValue {
82
+ const context = useContext(ClipboardContext);
83
+ if (!context) {
84
+ throw new Error("useClipboardContext must be used within a ClipboardProviderComponent");
85
+ }
86
+ return context;
87
+ }