fix: ios bugs
@@ -28,6 +28,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.10",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
},
|
||||
@@ -954,7 +955,7 @@
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
"semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
@@ -1108,6 +1109,10 @@
|
||||
|
||||
"@babel/core/json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
@@ -1118,19 +1123,19 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"is-bun-module/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
@@ -1138,8 +1143,6 @@
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"sharp/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.10",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
/* Terminal font (JetBrainsMono Nerd Font Mono) */
|
||||
@font-face {
|
||||
font-family: "JetBrainsMono Nerd Font Mono";
|
||||
src:
|
||||
url("/fonts/jetbrainsmono-nerd-font/JetBrainsMonoNerdFontMono-Regular.ttf")
|
||||
format("truetype");
|
||||
src: url("/fonts/jetbrainsmono-nerd-font/JetBrainsMonoNerdFontMono-Regular.ttf")
|
||||
format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -13,9 +12,8 @@
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrainsMono Nerd Font Mono";
|
||||
src:
|
||||
url("/fonts/jetbrainsmono-nerd-font/JetBrainsMonoNerdFontMono-Bold.ttf")
|
||||
format("truetype");
|
||||
src: url("/fonts/jetbrainsmono-nerd-font/JetBrainsMonoNerdFontMono-Bold.ttf")
|
||||
format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -27,27 +25,27 @@
|
||||
|
||||
:root {
|
||||
/* Backgrounds (dark mode) - No pure black */
|
||||
--background: 18 18 20; /* #121214 - primary surface */
|
||||
--background: 18 18 20; /* #121214 - primary surface */
|
||||
--background-elevated: 28 28 30; /* #1C1C1E - cards, panels */
|
||||
--background-tertiary: 44 44 46; /* #2C2C2E - nested elements */
|
||||
|
||||
/* Text Hierarchy */
|
||||
--foreground: 255 255 255; /* Primary text - white */
|
||||
--foreground: 255 255 255; /* Primary text - white */
|
||||
--foreground-secondary: 160 160 165; /* #A0A0A5 - secondary text */
|
||||
--foreground-tertiary: 110 110 115; /* #6E6E73 - tertiary/disabled */
|
||||
--foreground-muted: 72 72 77; /* #48484D - very subtle */
|
||||
--foreground-tertiary: 110 110 115; /* #6E6E73 - tertiary/disabled */
|
||||
--foreground-muted: 72 72 77; /* #48484D - very subtle */
|
||||
|
||||
/* Accent (single accent color - Indigo) */
|
||||
--accent: 99 102 241; /* Indigo-500 (#6366F1) */
|
||||
--accent: 99 102 241; /* Indigo-500 (#6366F1) */
|
||||
|
||||
/* Semantic Colors */
|
||||
--success: 34 197 94; /* #22C55E - emerald/green */
|
||||
--warning: 234 179 8; /* #EAB308 - amber */
|
||||
--error: 239 68 68; /* #EF4444 - red */
|
||||
--info: 59 130 246; /* #3B82F6 - blue */
|
||||
--success: 34 197 94; /* #22C55E - emerald/green */
|
||||
--warning: 234 179 8; /* #EAB308 - amber */
|
||||
--error: 239 68 68; /* #EF4444 - red */
|
||||
--info: 59 130 246; /* #3B82F6 - blue */
|
||||
|
||||
/* Borders - subtle */
|
||||
--border: 255 255 255; /* Used with /0.06 */
|
||||
--border: 255 255 255; /* Used with /0.06 */
|
||||
--border-elevated: 255 255 255; /* Used with /0.08 */
|
||||
}
|
||||
|
||||
@@ -78,8 +76,8 @@
|
||||
body {
|
||||
background-color: rgb(var(--background));
|
||||
color: rgb(var(--foreground));
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@@ -105,13 +103,6 @@ select:focus-visible {
|
||||
background: rgb(var(--accent) / 0.25);
|
||||
}
|
||||
|
||||
/* Fix selection visibility on purple/indigo backgrounds */
|
||||
.bg-indigo-500::selection,
|
||||
.bg-indigo-500 *::selection {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Scrollbars - minimal, subtle
|
||||
========================================================================= */
|
||||
@@ -137,23 +128,44 @@ select:focus-visible {
|
||||
Animations
|
||||
========================================================================= */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from { opacity: 0; transform: translateX(16px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-subtle {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
@@ -293,10 +305,18 @@ select:focus-visible {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.status-connected { background: rgb(var(--success)); }
|
||||
.status-disconnected { background: rgb(var(--error)); }
|
||||
.status-warning { background: rgb(var(--warning)); }
|
||||
.status-info { background: rgb(var(--info)); }
|
||||
.status-connected {
|
||||
background: rgb(var(--success));
|
||||
}
|
||||
.status-disconnected {
|
||||
background: rgb(var(--error));
|
||||
}
|
||||
.status-warning {
|
||||
background: rgb(var(--warning));
|
||||
}
|
||||
.status-info {
|
||||
background: rgb(var(--info));
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Badges/Tags
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
02DB7F25245D03FF72DD8E2E /* ControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84519FDE8FC75084938B292 /* ControlView.swift */; };
|
||||
03176DF3878C25A0B557462C /* ToolUIOptionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4D419C8490A0C5FC4DCDF20 /* ToolUIOptionListView.swift */; };
|
||||
0620B298DEF91DFCAE050DAC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 66A48A20D2178760301256C9 /* Assets.xcassets */; };
|
||||
0B5E1A6153270BFF21A54C23 /* TerminalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52DDF35DB8CD7D70F3CFC4A6 /* TerminalState.swift */; };
|
||||
1BBE749F3758FD704D1BFA0B /* ToolUIDataTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45213C3E550D451EDC566CDE /* ToolUIDataTableView.swift */; };
|
||||
29372E691F6A5C5D2CCD9331 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A09A33A3A1A99446C8A88DC /* HistoryView.swift */; };
|
||||
3361B14E949CB2A6E75B6962 /* ToolUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CBD2029F8CF6751AD7C4E2 /* ToolUIView.swift */; };
|
||||
@@ -17,6 +18,7 @@
|
||||
4B50B97618C0CC469FF64592 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504A1222CE8971417834D229 /* Theme.swift */; };
|
||||
4D0CF2666262F45370D000DF /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC6317C4EAD4DB9A8190209 /* TerminalView.swift */; };
|
||||
5152C5313CD5AC01276D0AE6 /* FileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA70A2A73D3A386EAFD69FC4 /* FileEntry.swift */; };
|
||||
652A0AE498D69C9DB728B2DF /* ANSIParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD8D224B6758B664864F3987 /* ANSIParser.swift */; };
|
||||
6865FE997D3E1D91D411F6BC /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B9834D4EE32058824F9DF00 /* LoadingView.swift */; };
|
||||
6B87076797C9DFA01E24CC76 /* FilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5908645A518F48B501390AB8 /* FilesView.swift */; };
|
||||
83BB0F0AAFE4F2735FF76B87 /* NavigationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3729F39FBF53046124D05BC1 /* NavigationState.swift */; };
|
||||
@@ -42,6 +44,7 @@
|
||||
4D3D6B3EA3B04DE534F9709A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
504A1222CE8971417834D229 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
|
||||
5267DE67017A858357F68424 /* GlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassButton.swift; sourceTree = "<group>"; };
|
||||
52DDF35DB8CD7D70F3CFC4A6 /* TerminalState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalState.swift; sourceTree = "<group>"; };
|
||||
5908645A518F48B501390AB8 /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = "<group>"; };
|
||||
5A09A33A3A1A99446C8A88DC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||
66A48A20D2178760301256C9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
@@ -52,6 +55,7 @@
|
||||
BA70A2A73D3A386EAFD69FC4 /* FileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntry.swift; sourceTree = "<group>"; };
|
||||
CBC90C32FEF604E025FFBF78 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
|
||||
CD6FB2E54DC07BE7A1EB08F8 /* StatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadge.swift; sourceTree = "<group>"; };
|
||||
CD8D224B6758B664864F3987 /* ANSIParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ANSIParser.swift; sourceTree = "<group>"; };
|
||||
D4AB47CF121ABA1946A4D879 /* Mission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mission.swift; sourceTree = "<group>"; };
|
||||
EB5A4720378F06807FDE73E1 /* GlassCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassCard.swift; sourceTree = "<group>"; };
|
||||
F51395D8FB559D3C79AAA0A4 /* OpenAgentDashboard.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = OpenAgentDashboard.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -178,8 +182,10 @@
|
||||
E9CA77690CC753DF6D133ACC /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CD8D224B6758B664864F3987 /* ANSIParser.swift */,
|
||||
CBC90C32FEF604E025FFBF78 /* APIService.swift */,
|
||||
3729F39FBF53046124D05BC1 /* NavigationState.swift */,
|
||||
52DDF35DB8CD7D70F3CFC4A6 /* TerminalState.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -254,6 +260,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
652A0AE498D69C9DB728B2DF /* ANSIParser.swift in Sources */,
|
||||
D64972881E36894950658708 /* APIService.swift in Sources */,
|
||||
CA70EC5A864C3D007D42E781 /* ChatMessage.swift in Sources */,
|
||||
AA02567226057045DDD61CB1 /* ContentView.swift in Sources */,
|
||||
@@ -268,6 +275,7 @@
|
||||
83BB0F0AAFE4F2735FF76B87 /* NavigationState.swift in Sources */,
|
||||
FF9C447978711CBA9185B8B0 /* OpenAgentDashboardApp.swift in Sources */,
|
||||
FA7E68F22D16E1AC0B5F5E22 /* StatusBadge.swift in Sources */,
|
||||
0B5E1A6153270BFF21A54C23 /* TerminalState.swift in Sources */,
|
||||
4D0CF2666262F45370D000DF /* TerminalView.swift in Sources */,
|
||||
4B50B97618C0CC469FF64592 /* Theme.swift in Sources */,
|
||||
1BBE749F3758FD704D1BFA0B /* ToolUIDataTableView.swift in Sources */,
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 957 B After Width: | Height: | Size: 472 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 729 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 2.3 KiB |
663
ios_dashboard/OpenAgentDashboard/Services/ANSIParser.swift
Normal file
@@ -0,0 +1,663 @@
|
||||
//
|
||||
// ANSIParser.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// State machine-based ANSI/VT100 escape sequence parser
|
||||
// Based on: https://vt100.net/emu/dec_ansi_parser
|
||||
// https://github.com/haberman/vtparse
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A proper state machine parser for ANSI/VT100 escape sequences
|
||||
/// Converts terminal output to AttributedString with colors
|
||||
final class ANSIParser {
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum State {
|
||||
case ground
|
||||
case escape
|
||||
case escapeIntermediate
|
||||
case csiEntry
|
||||
case csiParam
|
||||
case csiIntermediate
|
||||
case csiIgnore
|
||||
case oscString
|
||||
case dcsEntry
|
||||
case dcsParam
|
||||
case dcsIntermediate
|
||||
case dcsPassthrough
|
||||
case dcsIgnore
|
||||
case sosPmApcString
|
||||
}
|
||||
|
||||
struct TextStyle {
|
||||
var foreground: Color = .white
|
||||
var background: Color? = nil
|
||||
var bold: Bool = false
|
||||
var dim: Bool = false
|
||||
var italic: Bool = false
|
||||
var underline: Bool = false
|
||||
var blink: Bool = false
|
||||
var inverse: Bool = false
|
||||
var hidden: Bool = false
|
||||
var strikethrough: Bool = false
|
||||
|
||||
mutating func reset() {
|
||||
foreground = .white
|
||||
background = nil
|
||||
bold = false
|
||||
dim = false
|
||||
italic = false
|
||||
underline = false
|
||||
blink = false
|
||||
inverse = false
|
||||
hidden = false
|
||||
strikethrough = false
|
||||
}
|
||||
|
||||
var effectiveForeground: Color {
|
||||
let base = inverse ? (background ?? Color(white: 0.1)) : foreground
|
||||
return dim ? base.opacity(0.6) : base
|
||||
}
|
||||
|
||||
var effectiveBackground: Color? {
|
||||
inverse ? foreground : background
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
private var state: State = .ground
|
||||
private var intermediates: [UInt8] = []
|
||||
private var params: [Int] = []
|
||||
private var currentParam: Int = 0
|
||||
private var hasCurrentParam: Bool = false
|
||||
|
||||
private var style = TextStyle()
|
||||
private var result = AttributedString()
|
||||
private var currentText = ""
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Parse ANSI text and return AttributedString with colors
|
||||
static func parse(_ text: String) -> AttributedString {
|
||||
let parser = ANSIParser()
|
||||
return parser.process(text)
|
||||
}
|
||||
|
||||
/// Process input text and return attributed string
|
||||
func process(_ text: String) -> AttributedString {
|
||||
result = AttributedString()
|
||||
currentText = ""
|
||||
|
||||
for scalar in text.unicodeScalars {
|
||||
let byte = UInt8(min(scalar.value, 255))
|
||||
processByte(byte, char: Character(scalar))
|
||||
}
|
||||
|
||||
// Flush any remaining text
|
||||
flushText()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - State Machine
|
||||
|
||||
private func processByte(_ byte: UInt8, char: Character) {
|
||||
// Handle "anywhere" transitions first
|
||||
switch byte {
|
||||
case 0x18, 0x1A: // CAN, SUB - cancel sequence
|
||||
flushText()
|
||||
clear()
|
||||
state = .ground
|
||||
return
|
||||
case 0x1B: // ESC - start escape sequence
|
||||
flushText()
|
||||
clear()
|
||||
state = .escape
|
||||
return
|
||||
case 0x9B: // CSI (8-bit)
|
||||
flushText()
|
||||
clear()
|
||||
state = .csiEntry
|
||||
return
|
||||
case 0x9D: // OSC (8-bit)
|
||||
flushText()
|
||||
clear()
|
||||
state = .oscString
|
||||
return
|
||||
case 0x90: // DCS (8-bit)
|
||||
flushText()
|
||||
clear()
|
||||
state = .dcsEntry
|
||||
return
|
||||
case 0x98, 0x9E, 0x9F: // SOS, PM, APC (8-bit)
|
||||
flushText()
|
||||
clear()
|
||||
state = .sosPmApcString
|
||||
return
|
||||
case 0x9C: // ST (String Terminator)
|
||||
flushText()
|
||||
state = .ground
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// State-specific handling
|
||||
switch state {
|
||||
case .ground:
|
||||
handleGround(byte, char: char)
|
||||
case .escape:
|
||||
handleEscape(byte)
|
||||
case .escapeIntermediate:
|
||||
handleEscapeIntermediate(byte)
|
||||
case .csiEntry:
|
||||
handleCsiEntry(byte)
|
||||
case .csiParam:
|
||||
handleCsiParam(byte)
|
||||
case .csiIntermediate:
|
||||
handleCsiIntermediate(byte)
|
||||
case .csiIgnore:
|
||||
handleCsiIgnore(byte)
|
||||
case .oscString, .sosPmApcString:
|
||||
handleStringState(byte)
|
||||
case .dcsEntry:
|
||||
handleDcsEntry(byte)
|
||||
case .dcsParam:
|
||||
handleDcsParam(byte)
|
||||
case .dcsIntermediate:
|
||||
handleDcsIntermediate(byte)
|
||||
case .dcsPassthrough:
|
||||
handleDcsPassthrough(byte)
|
||||
case .dcsIgnore:
|
||||
handleDcsIgnore(byte)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State Handlers
|
||||
|
||||
private func handleGround(_ byte: UInt8, char: Character) {
|
||||
switch byte {
|
||||
case 0x00...0x1F:
|
||||
// C0 controls - execute (mostly ignore for display)
|
||||
if byte == 0x0A { // LF
|
||||
currentText.append("\n")
|
||||
} else if byte == 0x0D { // CR
|
||||
// Ignore CR (usually paired with LF)
|
||||
} else if byte == 0x09 { // TAB
|
||||
currentText.append("\t")
|
||||
}
|
||||
// Other C0 controls ignored
|
||||
case 0x20...0x7E:
|
||||
// Printable ASCII
|
||||
currentText.append(char)
|
||||
case 0x7F:
|
||||
// DEL - ignore
|
||||
break
|
||||
case 0xA0...0xFE:
|
||||
// Printable high bytes (treat like GL)
|
||||
currentText.append(char)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEscape(_ byte: UInt8) {
|
||||
switch byte {
|
||||
case 0x00...0x1F:
|
||||
// C0 controls - execute
|
||||
break
|
||||
case 0x20...0x2F:
|
||||
// Intermediate - collect and transition
|
||||
intermediates.append(byte)
|
||||
state = .escapeIntermediate
|
||||
case 0x30...0x4F, 0x51...0x57, 0x59, 0x5A, 0x5C, 0x60...0x7E:
|
||||
// Final characters - dispatch escape sequence
|
||||
dispatchEscape(byte)
|
||||
state = .ground
|
||||
case 0x5B: // '[' - CSI
|
||||
clear()
|
||||
state = .csiEntry
|
||||
case 0x5D: // ']' - OSC
|
||||
clear()
|
||||
state = .oscString
|
||||
case 0x50: // 'P' - DCS
|
||||
clear()
|
||||
state = .dcsEntry
|
||||
case 0x58, 0x5E, 0x5F: // 'X', '^', '_' - SOS, PM, APC
|
||||
clear()
|
||||
state = .sosPmApcString
|
||||
case 0x7F:
|
||||
// DEL - ignore
|
||||
break
|
||||
default:
|
||||
state = .ground
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEscapeIntermediate(_ byte: UInt8) {
|
||||
switch byte {
|
||||
case 0x00...0x1F:
|
||||
// C0 controls - execute
|
||||
break
|
||||
case 0x20...0x2F:
|
||||
// More intermediates
|
||||
intermediates.append(byte)
|
||||
case 0x30...0x7E:
|
||||
// Final - dispatch
|
||||
dispatchEscape(byte)
|
||||
state = .ground
|
||||
case 0x7F:
|
||||
// DEL - ignore
|
||||
break
|
||||
default:
|
||||
state = .ground
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCsiEntry(_ byte: UInt8) {
|
||||
switch byte {
|
||||
case 0x00...0x1F:
|
||||
// C0 controls - execute
|
||||
break
|
||||
case 0x20...0x2F:
|
||||
// Intermediate
|
||||
intermediates.append(byte)
|
||||
state = .csiIntermediate
|
||||
case 0x30...0x39: // '0'-'9'
|
||||
currentParam = Int(byte - 0x30)
|
||||
hasCurrentParam = true
|
||||
state = .csiParam
|
||||
case 0x3A: // ':' - subparameter (ignore sequence)
|
||||
state = .csiIgnore
|
||||
case 0x3B: // ';' - parameter separator
|
||||
params.append(0) // Default value
|
||||
state = .csiParam
|
||||
case 0x3C...0x3F: // '<', '=', '>', '?' - private marker
|
||||
intermediates.append(byte)
|
||||
state = .csiParam
|
||||
case 0x40...0x7E:
|
||||
// Final - dispatch
|
||||
dispatchCsi(byte)
|
||||
state = .ground
|
||||
case 0x7F:
|
||||
// DEL - ignore
|
||||
break
|
||||
default:
|
||||
state = .ground
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCsiParam(_ byte: UInt8) {
|
||||
switch byte {
|
||||
case 0x00...0x1F:
|
||||
// C0 controls - execute
|
||||
break
|
||||
case 0x20...0x2F:
|
||||
// Intermediate
|
||||
if hasCurrentParam {
|
||||
params.append(currentParam)
|
||||
currentParam = 0
|
||||
hasCurrentParam = false
|
||||
}
|
||||
intermediates.append(byte)
|
||||
state = .csiIntermediate
|
||||
case 0x30...0x39: // '0'-'9'
|
||||
currentParam = currentParam * 10 + Int(byte - 0x30)
|
||||
hasCurrentParam = true
|
||||
case 0x3A: // ':' - subparameter
|
||||
state = .csiIgnore
|
||||
case 0x3B: // ';' - parameter separator
|
||||
params.append(hasCurrentParam ? currentParam : 0)
|
||||
currentParam = 0
|
||||
hasCurrentParam = false
|
||||
case 0x3C...0x3F: // Private markers in wrong position
|
||||
state = .csiIgnore
|
||||
case 0x40...0x7E:
|
||||
// Final - dispatch
|
||||
if hasCurrentParam {
|
||||
params.append(currentParam)
|
||||
}
|
||||
dispatchCsi(byte)
|
||||
state = .ground
|
||||
case 0x7F:
|
||||
// DEL - ignore
|
||||
break
|
||||
default:
|
||||
state = .ground
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCsiIntermediate(_ byte: UInt8) {
|
||||
switch byte {
|
||||
case 0x00...0x1F:
|
||||
// C0 controls - execute
|
||||
break
|
||||
case 0x20...0x2F:
|
||||
// More intermediates
|
||||
intermediates.append(byte)
|
||||
case 0x30...0x3F:
|
||||
// Parameters after intermediate - error
|
||||
state = .csiIgnore
|
||||
case 0x40...0x7E:
|
||||
// Final - dispatch
|
||||
dispatchCsi(byte)
|
||||
state = .ground
|
||||
case 0x7F:
|
||||
// DEL - ignore
|
||||
break
|
||||
default:
|
||||
state = .ground
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCsiIgnore(_ byte: UInt8) {
|
||||
switch byte {
|
||||
case 0x00...0x1F:
|
||||
// C0 controls - execute
|
||||
break
|
||||
case 0x20...0x3F:
|
||||
// Ignore
|
||||
break
|
||||
case 0x40...0x7E:
|
||||
// Final - transition to ground (no dispatch)
|
||||
state = .ground
|
||||
case 0x7F:
|
||||
// DEL - ignore
|
||||
break
|
||||
default:
|
||||
state = .ground
|
||||
}
|
||||
}
|
||||
|
||||
private func handleStringState(_ byte: UInt8) {
|
||||
// OSC, SOS, PM, APC strings - ignore until ST
|
||||
switch byte {
|
||||
case 0x07: // BEL - alternative terminator for OSC
|
||||
state = .ground
|
||||
case 0x00...0x1F:
|
||||
// Ignore most C0
|
||||
break
|
||||
default:
|
||||
// Ignore string content
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDcsEntry(_ byte: UInt8) {
|
||||
// Similar to CSI entry but for device control strings
|
||||
switch byte {
|
||||
case 0x20...0x2F:
|
||||
intermediates.append(byte)
|
||||
state = .dcsIntermediate
|
||||
case 0x30...0x39, 0x3B:
|
||||
state = .dcsParam
|
||||
case 0x3C...0x3F:
|
||||
intermediates.append(byte)
|
||||
state = .dcsParam
|
||||
case 0x40...0x7E:
|
||||
state = .dcsPassthrough
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDcsParam(_ byte: UInt8) {
|
||||
switch byte {
|
||||
case 0x30...0x39, 0x3B:
|
||||
break // Collect params
|
||||
case 0x20...0x2F:
|
||||
state = .dcsIntermediate
|
||||
case 0x40...0x7E:
|
||||
state = .dcsPassthrough
|
||||
case 0x3A, 0x3C...0x3F:
|
||||
state = .dcsIgnore
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDcsIntermediate(_ byte: UInt8) {
|
||||
switch byte {
|
||||
case 0x20...0x2F:
|
||||
break // More intermediates
|
||||
case 0x40...0x7E:
|
||||
state = .dcsPassthrough
|
||||
case 0x30...0x3F:
|
||||
state = .dcsIgnore
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDcsPassthrough(_ byte: UInt8) {
|
||||
// Ignore DCS content
|
||||
}
|
||||
|
||||
private func handleDcsIgnore(_ byte: UInt8) {
|
||||
// Ignore until ST
|
||||
}
|
||||
|
||||
// MARK: - Dispatch
|
||||
|
||||
private func dispatchEscape(_ final: UInt8) {
|
||||
// Most escape sequences we don't care about for display
|
||||
// Could handle things like ESC 7 (save cursor) if needed
|
||||
}
|
||||
|
||||
private func dispatchCsi(_ final: UInt8) {
|
||||
// Check for private marker
|
||||
let isPrivate = !intermediates.isEmpty && intermediates[0] >= 0x3C && intermediates[0] <= 0x3F
|
||||
|
||||
switch final {
|
||||
case 0x6D: // 'm' - SGR (Select Graphic Rendition)
|
||||
if !isPrivate {
|
||||
handleSGR()
|
||||
}
|
||||
default:
|
||||
// Other CSI sequences (cursor movement, etc.) - ignore for display
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SGR (Colors and Styles)
|
||||
|
||||
private func handleSGR() {
|
||||
// If no parameters, treat as reset
|
||||
if params.isEmpty {
|
||||
style.reset()
|
||||
return
|
||||
}
|
||||
|
||||
var i = 0
|
||||
while i < params.count {
|
||||
let code = params[i]
|
||||
|
||||
switch code {
|
||||
case 0:
|
||||
style.reset()
|
||||
case 1:
|
||||
style.bold = true
|
||||
case 2:
|
||||
style.dim = true
|
||||
case 3:
|
||||
style.italic = true
|
||||
case 4:
|
||||
style.underline = true
|
||||
case 5, 6:
|
||||
style.blink = true
|
||||
case 7:
|
||||
style.inverse = true
|
||||
case 8:
|
||||
style.hidden = true
|
||||
case 9:
|
||||
style.strikethrough = true
|
||||
case 22:
|
||||
style.bold = false
|
||||
style.dim = false
|
||||
case 23:
|
||||
style.italic = false
|
||||
case 24:
|
||||
style.underline = false
|
||||
case 25:
|
||||
style.blink = false
|
||||
case 27:
|
||||
style.inverse = false
|
||||
case 28:
|
||||
style.hidden = false
|
||||
case 29:
|
||||
style.strikethrough = false
|
||||
|
||||
// Foreground colors (30-37)
|
||||
case 30: style.foreground = Color(white: 0.2)
|
||||
case 31: style.foreground = Color(red: 0.94, green: 0.33, blue: 0.31)
|
||||
case 32: style.foreground = Color(red: 0.33, green: 0.86, blue: 0.43)
|
||||
case 33: style.foreground = Color(red: 0.98, green: 0.74, blue: 0.25)
|
||||
case 34: style.foreground = Color(red: 0.40, green: 0.57, blue: 0.93)
|
||||
case 35: style.foreground = Color(red: 0.83, green: 0.42, blue: 0.78)
|
||||
case 36: style.foreground = Color(red: 0.30, green: 0.82, blue: 0.87)
|
||||
case 37: style.foreground = Color(white: 0.9)
|
||||
case 39: style.foreground = .white
|
||||
|
||||
// Background colors (40-47)
|
||||
case 40: style.background = Color(white: 0.1)
|
||||
case 41: style.background = Color(red: 0.6, green: 0.15, blue: 0.15)
|
||||
case 42: style.background = Color(red: 0.15, green: 0.5, blue: 0.2)
|
||||
case 43: style.background = Color(red: 0.6, green: 0.45, blue: 0.1)
|
||||
case 44: style.background = Color(red: 0.15, green: 0.25, blue: 0.55)
|
||||
case 45: style.background = Color(red: 0.5, green: 0.2, blue: 0.45)
|
||||
case 46: style.background = Color(red: 0.1, green: 0.45, blue: 0.5)
|
||||
case 47: style.background = Color(white: 0.7)
|
||||
case 49: style.background = nil
|
||||
|
||||
// Bright foreground (90-97)
|
||||
case 90: style.foreground = Color(white: 0.5)
|
||||
case 91: style.foreground = Color(red: 1, green: 0.45, blue: 0.45)
|
||||
case 92: style.foreground = Color(red: 0.45, green: 1, blue: 0.55)
|
||||
case 93: style.foreground = Color(red: 1, green: 0.9, blue: 0.45)
|
||||
case 94: style.foreground = Color(red: 0.55, green: 0.7, blue: 1)
|
||||
case 95: style.foreground = Color(red: 1, green: 0.55, blue: 0.95)
|
||||
case 96: style.foreground = Color(red: 0.45, green: 0.95, blue: 1)
|
||||
case 97: style.foreground = .white
|
||||
|
||||
// Bright background (100-107)
|
||||
case 100: style.background = Color(white: 0.4)
|
||||
case 101: style.background = Color(red: 0.8, green: 0.3, blue: 0.3)
|
||||
case 102: style.background = Color(red: 0.3, green: 0.7, blue: 0.35)
|
||||
case 103: style.background = Color(red: 0.8, green: 0.65, blue: 0.2)
|
||||
case 104: style.background = Color(red: 0.35, green: 0.45, blue: 0.75)
|
||||
case 105: style.background = Color(red: 0.7, green: 0.4, blue: 0.65)
|
||||
case 106: style.background = Color(red: 0.25, green: 0.65, blue: 0.7)
|
||||
case 107: style.background = Color(white: 0.85)
|
||||
|
||||
// 256 color mode (38;5;n or 48;5;n)
|
||||
case 38:
|
||||
if i + 2 < params.count && params[i + 1] == 5 {
|
||||
style.foreground = color256(params[i + 2])
|
||||
i += 2
|
||||
} else if i + 4 < params.count && params[i + 1] == 2 {
|
||||
// True color: 38;2;r;g;b
|
||||
style.foreground = Color(
|
||||
red: Double(params[i + 2]) / 255.0,
|
||||
green: Double(params[i + 3]) / 255.0,
|
||||
blue: Double(params[i + 4]) / 255.0
|
||||
)
|
||||
i += 4
|
||||
}
|
||||
case 48:
|
||||
if i + 2 < params.count && params[i + 1] == 5 {
|
||||
style.background = color256(params[i + 2])
|
||||
i += 2
|
||||
} else if i + 4 < params.count && params[i + 1] == 2 {
|
||||
// True color: 48;2;r;g;b
|
||||
style.background = Color(
|
||||
red: Double(params[i + 2]) / 255.0,
|
||||
green: Double(params[i + 3]) / 255.0,
|
||||
blue: Double(params[i + 4]) / 255.0
|
||||
)
|
||||
i += 4
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func clear() {
|
||||
intermediates.removeAll()
|
||||
params.removeAll()
|
||||
currentParam = 0
|
||||
hasCurrentParam = false
|
||||
}
|
||||
|
||||
private func flushText() {
|
||||
guard !currentText.isEmpty else { return }
|
||||
|
||||
var attr = AttributedString(currentText)
|
||||
attr.foregroundColor = style.effectiveForeground
|
||||
attr.font = .system(size: 13, weight: style.bold ? .bold : .regular, design: .monospaced)
|
||||
|
||||
if let bg = style.effectiveBackground {
|
||||
attr.backgroundColor = bg
|
||||
}
|
||||
if style.underline {
|
||||
attr.underlineStyle = .single
|
||||
}
|
||||
if style.strikethrough {
|
||||
attr.strikethroughStyle = .single
|
||||
}
|
||||
|
||||
result.append(attr)
|
||||
currentText = ""
|
||||
}
|
||||
|
||||
private func color256(_ index: Int) -> Color {
|
||||
guard index >= 0 && index < 256 else { return .white }
|
||||
|
||||
if index < 16 {
|
||||
// Standard colors
|
||||
let colors: [Color] = [
|
||||
Color(white: 0.1), // 0: Black
|
||||
Color(red: 0.8, green: 0.2, blue: 0.2), // 1: Red
|
||||
Color(red: 0.2, green: 0.8, blue: 0.3), // 2: Green
|
||||
Color(red: 0.8, green: 0.7, blue: 0.2), // 3: Yellow
|
||||
Color(red: 0.3, green: 0.4, blue: 0.9), // 4: Blue
|
||||
Color(red: 0.8, green: 0.3, blue: 0.7), // 5: Magenta
|
||||
Color(red: 0.2, green: 0.7, blue: 0.8), // 6: Cyan
|
||||
Color(white: 0.85), // 7: White
|
||||
Color(white: 0.4), // 8: Bright Black
|
||||
Color(red: 1, green: 0.4, blue: 0.4), // 9: Bright Red
|
||||
Color(red: 0.4, green: 1, blue: 0.5), // 10: Bright Green
|
||||
Color(red: 1, green: 0.95, blue: 0.4), // 11: Bright Yellow
|
||||
Color(red: 0.5, green: 0.6, blue: 1), // 12: Bright Blue
|
||||
Color(red: 1, green: 0.5, blue: 0.9), // 13: Bright Magenta
|
||||
Color(red: 0.4, green: 0.95, blue: 1), // 14: Bright Cyan
|
||||
.white // 15: Bright White
|
||||
]
|
||||
return colors[index]
|
||||
} else if index < 232 {
|
||||
// 216 color cube (6x6x6)
|
||||
let n = index - 16
|
||||
let b = n % 6
|
||||
let g = (n / 6) % 6
|
||||
let r = n / 36
|
||||
return Color(
|
||||
red: r == 0 ? 0 : Double(r * 40 + 55) / 255.0,
|
||||
green: g == 0 ? 0 : Double(g * 40 + 55) / 255.0,
|
||||
blue: b == 0 ? 0 : Double(b * 40 + 55) / 255.0
|
||||
)
|
||||
} else {
|
||||
// Grayscale (24 shades)
|
||||
let gray = Double((index - 232) * 10 + 8) / 255.0
|
||||
return Color(white: gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// TerminalState.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Persistent terminal state that survives tab switches
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TerminalState {
|
||||
static let shared = TerminalState()
|
||||
|
||||
var terminalOutput: [TerminalLine] = []
|
||||
var connectionStatus: StatusType = .disconnected
|
||||
var webSocketTask: URLSessionWebSocketTask?
|
||||
var isConnecting = false
|
||||
|
||||
private init() {}
|
||||
|
||||
func appendLine(_ line: TerminalLine) {
|
||||
terminalOutput.append(line)
|
||||
// Limit buffer size to prevent memory issues
|
||||
if terminalOutput.count > 2000 {
|
||||
terminalOutput.removeFirst(500)
|
||||
}
|
||||
}
|
||||
|
||||
func appendOutput(_ text: String) {
|
||||
let line = TerminalLine(text: text, type: .output)
|
||||
appendLine(line)
|
||||
}
|
||||
|
||||
func appendInput(_ text: String) {
|
||||
let line = TerminalLine(text: "$ \(text)", type: .input)
|
||||
appendLine(line)
|
||||
}
|
||||
|
||||
func appendError(_ text: String) {
|
||||
let line = TerminalLine(text: text, type: .error)
|
||||
appendLine(line)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
terminalOutput = []
|
||||
}
|
||||
}
|
||||
|
||||
// TerminalLine struct using the proper ANSI parser
|
||||
struct TerminalLine: Identifiable {
|
||||
let id = UUID()
|
||||
let text: String
|
||||
let type: LineType
|
||||
let timestamp = Date()
|
||||
var attributedText: AttributedString?
|
||||
|
||||
enum LineType {
|
||||
case input
|
||||
case output
|
||||
case error
|
||||
case system
|
||||
}
|
||||
|
||||
init(text: String, type: LineType) {
|
||||
self.text = text
|
||||
self.type = type
|
||||
|
||||
// Parse ANSI for output lines using the proper state machine parser
|
||||
if type == .output {
|
||||
self.attributedText = ANSIParser.parse(text)
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch type {
|
||||
case .input: return Theme.accent
|
||||
case .output: return Theme.textPrimary
|
||||
case .error: return Theme.error
|
||||
case .system: return Theme.textTertiary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,69 +22,83 @@ struct ToolUIDataTableView: View {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Row count badge
|
||||
Text("\(table.rows.count) rows")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.backgroundSecondary)
|
||||
}
|
||||
|
||||
// Table content with horizontal scroll
|
||||
ScrollView(.horizontal, showsIndicators: true) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header row
|
||||
HStack(spacing: 0) {
|
||||
ForEach(table.columns, id: \.id) { column in
|
||||
Text(column.displayLabel)
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
.textCase(.uppercase)
|
||||
.lineLimit(2)
|
||||
.frame(width: columnWidth(for: column), alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
// Debug info if no columns
|
||||
if table.columns.isEmpty {
|
||||
Text("No columns defined")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
.padding()
|
||||
} else {
|
||||
// Table content with horizontal scroll
|
||||
ScrollView(.horizontal, showsIndicators: true) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header row
|
||||
HStack(spacing: 0) {
|
||||
ForEach(table.columns, id: \.id) { column in
|
||||
Text(column.displayLabel)
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
.textCase(.uppercase)
|
||||
.lineLimit(2)
|
||||
.frame(width: columnWidth(for: column), alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.white.opacity(0.03))
|
||||
|
||||
Rectangle()
|
||||
.fill(Theme.border)
|
||||
.frame(height: 0.5)
|
||||
|
||||
// Data rows
|
||||
if table.rows.isEmpty {
|
||||
Text("No data")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
} else {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(table.rows.enumerated()), id: \.offset) { index, row in
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(table.columns.enumerated()), id: \.element.id) { colIndex, column in
|
||||
let cellValue = row[column.id]?.stringValue ?? "-"
|
||||
|
||||
Text(cellValue)
|
||||
.font(.caption)
|
||||
.foregroundStyle(colIndex == 0 ? Theme.textPrimary : Theme.textSecondary)
|
||||
.fontWeight(colIndex == 0 ? .medium : .regular)
|
||||
.lineLimit(3)
|
||||
.frame(width: columnWidth(for: column), alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.white.opacity(0.03))
|
||||
|
||||
Rectangle()
|
||||
.fill(Theme.border)
|
||||
.frame(height: 0.5)
|
||||
|
||||
// Data rows
|
||||
if table.rows.isEmpty {
|
||||
Text("No data")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
.padding()
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(table.rows.enumerated()), id: \.offset) { index, row in
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(table.columns.enumerated()), id: \.element.id) { colIndex, column in
|
||||
let cellValue = getCellValue(row: row, columnId: column.id)
|
||||
|
||||
Text(cellValue)
|
||||
.font(.caption)
|
||||
.foregroundStyle(colIndex == 0 ? Theme.textPrimary : Theme.textSecondary)
|
||||
.fontWeight(colIndex == 0 ? .medium : .regular)
|
||||
.lineLimit(3)
|
||||
.frame(width: columnWidth(for: column), alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
.background(index % 2 == 0 ? Color.clear : Color.white.opacity(0.02))
|
||||
|
||||
if index < table.rows.count - 1 {
|
||||
Rectangle()
|
||||
.fill(Theme.border.opacity(0.3))
|
||||
.frame(height: 0.5)
|
||||
}
|
||||
}
|
||||
.background(index % 2 == 0 ? Color.clear : Color.white.opacity(0.02))
|
||||
|
||||
if index < table.rows.count - 1 {
|
||||
Rectangle()
|
||||
.fill(Theme.border.opacity(0.3))
|
||||
.frame(height: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: totalTableWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +110,25 @@ struct ToolUIDataTableView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func getCellValue(row: [String: AnyCodable], columnId: String) -> String {
|
||||
// Try exact match first
|
||||
if let value = row[columnId] {
|
||||
return value.stringValue
|
||||
}
|
||||
// Try case-insensitive match
|
||||
let lowerId = columnId.lowercased()
|
||||
for (key, value) in row {
|
||||
if key.lowercased() == lowerId {
|
||||
return value.stringValue
|
||||
}
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
private var totalTableWidth: CGFloat {
|
||||
table.columns.reduce(0) { $0 + columnWidth(for: $1) + 20 } // 20 for padding
|
||||
}
|
||||
|
||||
private func columnWidth(for column: ToolUIDataTable.Column) -> CGFloat {
|
||||
// Parse width if provided, otherwise use adaptive default
|
||||
if let width = column.width {
|
||||
|
||||
@@ -172,6 +172,10 @@ struct ControlView: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onTapGesture {
|
||||
// Dismiss keyboard when tapping on messages area
|
||||
isInputFocused = false
|
||||
}
|
||||
.onChange(of: messages.count) { _, _ in
|
||||
if let lastMessage = messages.last {
|
||||
withAnimation {
|
||||
@@ -574,9 +578,7 @@ private struct MessageBubble: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text(message.content)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
MarkdownText(message.content)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
@@ -675,6 +677,29 @@ private struct FlowLayout: Layout {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Markdown Text
|
||||
|
||||
private struct MarkdownText: View {
|
||||
let content: String
|
||||
|
||||
init(_ content: String) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let attributed = try? AttributedString(markdown: content, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
|
||||
Text(attributed)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.tint(Theme.accent)
|
||||
} else {
|
||||
Text(content)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
ControlView()
|
||||
|
||||
@@ -15,6 +15,9 @@ struct FilesView: View {
|
||||
@State private var errorMessage: String?
|
||||
@State private var selectedEntry: FileEntry?
|
||||
@State private var showingDeleteAlert = false
|
||||
@State private var isEditingPath = false
|
||||
@State private var editedPath = ""
|
||||
@FocusState private var isPathFieldFocused: Bool
|
||||
@State private var showingNewFolderAlert = false
|
||||
@State private var newFolderName = ""
|
||||
@State private var isImporting = false
|
||||
@@ -178,7 +181,7 @@ struct FilesView: View {
|
||||
private var breadcrumbView: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Up button
|
||||
if currentPath != "/" {
|
||||
if currentPath != "/" && !isEditingPath {
|
||||
Button {
|
||||
goUp()
|
||||
} label: {
|
||||
@@ -189,33 +192,80 @@ struct FilesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Breadcrumb path
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(Array(breadcrumbs.enumerated()), id: \.offset) { index, crumb in
|
||||
if index > 0 {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
}
|
||||
|
||||
Button {
|
||||
navigateTo(crumb.path)
|
||||
} label: {
|
||||
Text(crumb.name)
|
||||
.font(.subheadline.weight(index == breadcrumbs.count - 1 ? .semibold : .medium))
|
||||
.foregroundStyle(index == breadcrumbs.count - 1 ? Theme.textPrimary : Theme.textTertiary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(index == breadcrumbs.count - 1 ? Theme.backgroundSecondary : .clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
if isEditingPath {
|
||||
// Editable path text field
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "folder")
|
||||
.foregroundStyle(Theme.accent)
|
||||
|
||||
TextField("Path", text: $editedPath)
|
||||
.font(.subheadline.monospaced())
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($isPathFieldFocused)
|
||||
.onSubmit {
|
||||
navigateTo(editedPath)
|
||||
isEditingPath = false
|
||||
}
|
||||
|
||||
Button {
|
||||
isEditingPath = false
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
}
|
||||
|
||||
Button {
|
||||
navigateTo(editedPath)
|
||||
isEditingPath = false
|
||||
} label: {
|
||||
Image(systemName: "arrow.right.circle.fill")
|
||||
.foregroundStyle(Theme.accent)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.horizontal, 16)
|
||||
} else {
|
||||
// Breadcrumb path
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(Array(breadcrumbs.enumerated()), id: \.offset) { index, crumb in
|
||||
if index > 0 {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
}
|
||||
|
||||
Button {
|
||||
navigateTo(crumb.path)
|
||||
} label: {
|
||||
Text(crumb.name)
|
||||
.font(.subheadline.weight(index == breadcrumbs.count - 1 ? .semibold : .medium))
|
||||
.foregroundStyle(index == breadcrumbs.count - 1 ? Theme.textPrimary : Theme.textTertiary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(index == breadcrumbs.count - 1 ? Theme.backgroundSecondary : .clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
// Edit button
|
||||
Button {
|
||||
editedPath = currentPath
|
||||
isEditingPath = true
|
||||
isPathFieldFocused = true
|
||||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, currentPath == "/" ? 16 : 0)
|
||||
.padding(.leading, currentPath == "/" && !isEditingPath ? 16 : 0)
|
||||
.frame(height: 44)
|
||||
.background(.thinMaterial)
|
||||
}
|
||||
|
||||
@@ -8,267 +8,17 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TerminalView: View {
|
||||
@State private var terminalOutput: [TerminalLine] = []
|
||||
private var state = TerminalState.shared
|
||||
@State private var inputText = ""
|
||||
@State private var connectionStatus: StatusType = .disconnected
|
||||
@State private var webSocketTask: URLSessionWebSocketTask?
|
||||
@State private var isConnecting = false
|
||||
|
||||
@FocusState private var isInputFocused: Bool
|
||||
|
||||
private let api = APIService.shared
|
||||
|
||||
struct TerminalLine: Identifiable {
|
||||
let id = UUID()
|
||||
let text: String
|
||||
let type: LineType
|
||||
let attributedText: AttributedString?
|
||||
|
||||
enum LineType {
|
||||
case input
|
||||
case output
|
||||
case error
|
||||
case system
|
||||
}
|
||||
|
||||
init(text: String, type: LineType) {
|
||||
self.text = text
|
||||
self.type = type
|
||||
self.attributedText = type == .output ? Self.parseANSI(text) : nil
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch type {
|
||||
case .input: return Theme.accent
|
||||
case .output: return Theme.textPrimary
|
||||
case .error: return Theme.error
|
||||
case .system: return Theme.textTertiary
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse ANSI escape codes and return AttributedString with colors
|
||||
private static func parseANSI(_ text: String) -> AttributedString? {
|
||||
// First, strip ALL non-SGR escape sequences (cursor movement, etc.)
|
||||
let cleanedText = stripNonColorEscapes(text)
|
||||
|
||||
var result = AttributedString()
|
||||
var currentColor: Color = .white
|
||||
var currentBgColor: Color? = nil
|
||||
var isBold = false
|
||||
var isDim = false
|
||||
|
||||
// Pattern to match SGR (color/style) escape sequences only
|
||||
let pattern = "\u{001B}\\[([0-9;]*)m"
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
|
||||
// If regex fails, return plain text
|
||||
var attr = AttributedString(cleanedText)
|
||||
attr.foregroundColor = .white
|
||||
attr.font = .system(size: 13, weight: .regular, design: .monospaced)
|
||||
return attr
|
||||
}
|
||||
|
||||
let nsText = cleanedText as NSString
|
||||
var lastEnd = 0
|
||||
let matches = regex.matches(in: cleanedText, options: [], range: NSRange(location: 0, length: nsText.length))
|
||||
|
||||
for match in matches {
|
||||
// Add text before this escape sequence
|
||||
if match.range.location > lastEnd {
|
||||
let textRange = NSRange(location: lastEnd, length: match.range.location - lastEnd)
|
||||
let substring = nsText.substring(with: textRange)
|
||||
if !substring.isEmpty {
|
||||
var attr = AttributedString(substring)
|
||||
attr.foregroundColor = isDim ? currentColor.opacity(0.6) : currentColor
|
||||
attr.font = .system(size: 13, weight: isBold ? .bold : .regular, design: .monospaced)
|
||||
if let bg = currentBgColor {
|
||||
attr.backgroundColor = bg
|
||||
}
|
||||
result.append(attr)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the SGR codes
|
||||
if match.numberOfRanges > 1 {
|
||||
let codeRange = match.range(at: 1)
|
||||
let codeString = nsText.substring(with: codeRange)
|
||||
let codes = codeString.isEmpty ? [0] : codeString.split(separator: ";").compactMap { Int($0) }
|
||||
|
||||
var i = 0
|
||||
while i < codes.count {
|
||||
let code = codes[i]
|
||||
switch code {
|
||||
case 0: // Reset all
|
||||
currentColor = .white
|
||||
currentBgColor = nil
|
||||
isBold = false
|
||||
isDim = false
|
||||
case 1: isBold = true
|
||||
case 2: isDim = true
|
||||
case 22: isBold = false; isDim = false
|
||||
// Foreground colors (30-37, 90-97)
|
||||
case 30: currentColor = Color(white: 0.2)
|
||||
case 31: currentColor = Color(red: 0.94, green: 0.33, blue: 0.31)
|
||||
case 32: currentColor = Color(red: 0.33, green: 0.86, blue: 0.43)
|
||||
case 33: currentColor = Color(red: 0.98, green: 0.74, blue: 0.25)
|
||||
case 34: currentColor = Color(red: 0.40, green: 0.57, blue: 0.93)
|
||||
case 35: currentColor = Color(red: 0.83, green: 0.42, blue: 0.78)
|
||||
case 36: currentColor = Color(red: 0.30, green: 0.82, blue: 0.87)
|
||||
case 37: currentColor = Color(white: 0.9)
|
||||
case 39: currentColor = .white // Default
|
||||
case 90: currentColor = Color(white: 0.5)
|
||||
case 91: currentColor = Color(red: 1, green: 0.45, blue: 0.45)
|
||||
case 92: currentColor = Color(red: 0.45, green: 1, blue: 0.55)
|
||||
case 93: currentColor = Color(red: 1, green: 0.9, blue: 0.45)
|
||||
case 94: currentColor = Color(red: 0.55, green: 0.7, blue: 1)
|
||||
case 95: currentColor = Color(red: 1, green: 0.55, blue: 0.95)
|
||||
case 96: currentColor = Color(red: 0.45, green: 0.95, blue: 1)
|
||||
case 97: currentColor = .white
|
||||
// Background colors (40-47, 100-107)
|
||||
case 40: currentBgColor = Color(white: 0.1)
|
||||
case 41: currentBgColor = Color(red: 0.6, green: 0.15, blue: 0.15)
|
||||
case 42: currentBgColor = Color(red: 0.15, green: 0.5, blue: 0.2)
|
||||
case 43: currentBgColor = Color(red: 0.6, green: 0.45, blue: 0.1)
|
||||
case 44: currentBgColor = Color(red: 0.15, green: 0.25, blue: 0.55)
|
||||
case 45: currentBgColor = Color(red: 0.5, green: 0.2, blue: 0.45)
|
||||
case 46: currentBgColor = Color(red: 0.1, green: 0.45, blue: 0.5)
|
||||
case 47: currentBgColor = Color(white: 0.7)
|
||||
case 49: currentBgColor = nil // Default bg
|
||||
// 256 color mode (38;5;n or 48;5;n)
|
||||
case 38:
|
||||
if i + 2 < codes.count && codes[i + 1] == 5 {
|
||||
currentColor = color256(codes[i + 2])
|
||||
i += 2
|
||||
}
|
||||
case 48:
|
||||
if i + 2 < codes.count && codes[i + 1] == 5 {
|
||||
currentBgColor = color256(codes[i + 2])
|
||||
i += 2
|
||||
}
|
||||
default: break
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
lastEnd = match.range.location + match.range.length
|
||||
}
|
||||
|
||||
// Add remaining text after last escape sequence
|
||||
if lastEnd < nsText.length {
|
||||
let textRange = NSRange(location: lastEnd, length: nsText.length - lastEnd)
|
||||
let substring = nsText.substring(with: textRange)
|
||||
if !substring.isEmpty {
|
||||
var attr = AttributedString(substring)
|
||||
attr.foregroundColor = isDim ? currentColor.opacity(0.6) : currentColor
|
||||
attr.font = .system(size: 13, weight: isBold ? .bold : .regular, design: .monospaced)
|
||||
if let bg = currentBgColor {
|
||||
attr.backgroundColor = bg
|
||||
}
|
||||
result.append(attr)
|
||||
}
|
||||
}
|
||||
|
||||
return result.characters.isEmpty ? nil : result
|
||||
}
|
||||
|
||||
/// Strip all non-SGR escape sequences (cursor movement, screen clear, etc.)
|
||||
private static func stripNonColorEscapes(_ text: String) -> String {
|
||||
var result = text
|
||||
|
||||
// Pattern 1: CSI sequences that are NOT color codes (not ending in 'm')
|
||||
// This catches [47C (cursor forward), [1G (cursor to column), [2J (clear), etc.
|
||||
// Using a more explicit pattern to catch all CSI commands except 'm'
|
||||
let csiPattern = "\\x1B\\[([0-9]*;?)*[ABCDEFGHIJKLPSTXZcfghilnqrsu@`]"
|
||||
if let regex = try? NSRegularExpression(pattern: csiPattern, options: []) {
|
||||
result = regex.stringByReplacingMatches(
|
||||
in: result,
|
||||
options: [],
|
||||
range: NSRange(result.startIndex..., in: result),
|
||||
withTemplate: ""
|
||||
)
|
||||
}
|
||||
|
||||
// Pattern 2: Also catch any remaining [xxC or [xxA etc. patterns that might have slipped through
|
||||
// This is a fallback for malformed sequences
|
||||
let fallbackPattern = "\\[\\d+[A-Za-z]"
|
||||
if let regex = try? NSRegularExpression(pattern: fallbackPattern, options: []) {
|
||||
result = regex.stringByReplacingMatches(
|
||||
in: result,
|
||||
options: [],
|
||||
range: NSRange(result.startIndex..., in: result),
|
||||
withTemplate: ""
|
||||
)
|
||||
}
|
||||
|
||||
// Pattern 3: OSC sequences (ESC ] ... BEL or ESC ] ... ST)
|
||||
let oscPattern = "\\x1B\\][^\\x07\\x1B]*(?:\\x07|\\x1B\\\\)?"
|
||||
if let regex = try? NSRegularExpression(pattern: oscPattern, options: []) {
|
||||
result = regex.stringByReplacingMatches(
|
||||
in: result,
|
||||
options: [],
|
||||
range: NSRange(result.startIndex..., in: result),
|
||||
withTemplate: ""
|
||||
)
|
||||
}
|
||||
|
||||
// Pattern 4: Private mode sequences (ESC [ ? ... h/l)
|
||||
let privatePattern = "\\x1B\\[\\?[0-9;]*[hl]"
|
||||
if let regex = try? NSRegularExpression(pattern: privatePattern, options: []) {
|
||||
result = regex.stringByReplacingMatches(
|
||||
in: result,
|
||||
options: [],
|
||||
range: NSRange(result.startIndex..., in: result),
|
||||
withTemplate: ""
|
||||
)
|
||||
}
|
||||
|
||||
// Pattern 5: Character set and single-char escapes
|
||||
let miscPattern = "\\x1B[\\(\\)][AB012]|\\x1B[78DEHM=>]"
|
||||
if let regex = try? NSRegularExpression(pattern: miscPattern, options: []) {
|
||||
result = regex.stringByReplacingMatches(
|
||||
in: result,
|
||||
options: [],
|
||||
range: NSRange(result.startIndex..., in: result),
|
||||
withTemplate: ""
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Convert 256-color palette index to Color
|
||||
private static func color256(_ index: Int) -> Color {
|
||||
if index < 16 {
|
||||
// Standard colors
|
||||
let colors: [Color] = [
|
||||
Color(white: 0.1), Color(red: 0.8, green: 0.2, blue: 0.2),
|
||||
Color(red: 0.2, green: 0.8, blue: 0.3), Color(red: 0.8, green: 0.7, blue: 0.2),
|
||||
Color(red: 0.3, green: 0.4, blue: 0.9), Color(red: 0.8, green: 0.3, blue: 0.7),
|
||||
Color(red: 0.2, green: 0.7, blue: 0.8), Color(white: 0.85),
|
||||
Color(white: 0.4), Color(red: 1, green: 0.4, blue: 0.4),
|
||||
Color(red: 0.4, green: 1, blue: 0.5), Color(red: 1, green: 0.95, blue: 0.4),
|
||||
Color(red: 0.5, green: 0.6, blue: 1), Color(red: 1, green: 0.5, blue: 0.9),
|
||||
Color(red: 0.4, green: 0.95, blue: 1), .white
|
||||
]
|
||||
return colors[index]
|
||||
} else if index < 232 {
|
||||
// 216 color cube (6x6x6)
|
||||
let n = index - 16
|
||||
let b = n % 6
|
||||
let g = (n / 6) % 6
|
||||
let r = n / 36
|
||||
return Color(
|
||||
red: r == 0 ? 0 : Double(r * 40 + 55) / 255,
|
||||
green: g == 0 ? 0 : Double(g * 40 + 55) / 255,
|
||||
blue: b == 0 ? 0 : Double(b * 40 + 55) / 255
|
||||
)
|
||||
} else {
|
||||
// Grayscale (24 shades)
|
||||
let gray = Double((index - 232) * 10 + 8) / 255
|
||||
return Color(white: gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Convenience accessors
|
||||
private var terminalOutput: [TerminalLine] { state.terminalOutput }
|
||||
private var connectionStatus: StatusType { state.connectionStatus }
|
||||
private var isConnecting: Bool { state.isConnecting }
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
@@ -471,16 +221,16 @@ struct TerminalView: View {
|
||||
// MARK: - WebSocket Connection
|
||||
|
||||
private func connect() {
|
||||
guard connectionStatus != .connected && !isConnecting else { return }
|
||||
guard state.connectionStatus != .connected && !state.isConnecting else { return }
|
||||
|
||||
isConnecting = true
|
||||
connectionStatus = .connecting
|
||||
addSystemLine("Connecting to \(api.baseURL)...")
|
||||
state.isConnecting = true
|
||||
state.connectionStatus = .connecting
|
||||
state.appendLine(TerminalLine(text: "Connecting to \(api.baseURL)...", type: .system))
|
||||
|
||||
guard let wsURL = buildWebSocketURL() else {
|
||||
addErrorLine("Invalid WebSocket URL")
|
||||
connectionStatus = .error
|
||||
isConnecting = false
|
||||
state.appendLine(TerminalLine(text: "Invalid WebSocket URL", type: .error))
|
||||
state.connectionStatus = .error
|
||||
state.isConnecting = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -491,28 +241,28 @@ struct TerminalView: View {
|
||||
request.setValue("openagent, jwt.\(token)", forHTTPHeaderField: "Sec-WebSocket-Protocol")
|
||||
}
|
||||
|
||||
webSocketTask = URLSession.shared.webSocketTask(with: request)
|
||||
webSocketTask?.resume()
|
||||
state.webSocketTask = URLSession.shared.webSocketTask(with: request)
|
||||
state.webSocketTask?.resume()
|
||||
|
||||
// Start receiving messages
|
||||
receiveMessages()
|
||||
|
||||
// Send initial resize message after a brief delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
if connectionStatus == .connecting {
|
||||
connectionStatus = .connected
|
||||
addSystemLine("Connected.")
|
||||
if state.connectionStatus == .connecting {
|
||||
state.connectionStatus = .connected
|
||||
state.appendLine(TerminalLine(text: "Connected.", type: .system))
|
||||
}
|
||||
isConnecting = false
|
||||
state.isConnecting = false
|
||||
sendResize(cols: 80, rows: 24)
|
||||
}
|
||||
}
|
||||
|
||||
private func disconnect() {
|
||||
webSocketTask?.cancel(with: .normalClosure, reason: nil)
|
||||
webSocketTask = nil
|
||||
connectionStatus = .disconnected
|
||||
addSystemLine("Disconnected.")
|
||||
state.webSocketTask?.cancel(with: .normalClosure, reason: nil)
|
||||
state.webSocketTask = nil
|
||||
state.connectionStatus = .disconnected
|
||||
state.appendLine(TerminalLine(text: "Disconnected.", type: .system))
|
||||
}
|
||||
|
||||
private func buildWebSocketURL() -> URL? {
|
||||
@@ -523,17 +273,17 @@ struct TerminalView: View {
|
||||
}
|
||||
|
||||
private func receiveMessages() {
|
||||
webSocketTask?.receive { [self] result in
|
||||
state.webSocketTask?.receive { [self] result in
|
||||
switch result {
|
||||
case .success(let message):
|
||||
switch message {
|
||||
case .string(let text):
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
self.handleOutput(text)
|
||||
}
|
||||
case .data(let data):
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
DispatchQueue.main.async {
|
||||
Task { @MainActor in
|
||||
self.handleOutput(text)
|
||||
}
|
||||
}
|
||||
@@ -544,10 +294,10 @@ struct TerminalView: View {
|
||||
receiveMessages()
|
||||
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
if connectionStatus != .disconnected {
|
||||
connectionStatus = .error
|
||||
addErrorLine("Connection error: \(error.localizedDescription)")
|
||||
Task { @MainActor in
|
||||
if state.connectionStatus != .disconnected {
|
||||
state.connectionStatus = .error
|
||||
state.appendLine(TerminalLine(text: "Connection error: \(error.localizedDescription)", type: .error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -559,33 +309,28 @@ struct TerminalView: View {
|
||||
let lines = text.components(separatedBy: .newlines)
|
||||
for line in lines {
|
||||
if !line.isEmpty {
|
||||
terminalOutput.append(TerminalLine(text: line, type: .output))
|
||||
state.appendLine(TerminalLine(text: line, type: .output))
|
||||
}
|
||||
}
|
||||
|
||||
// Limit history
|
||||
if terminalOutput.count > 1000 {
|
||||
terminalOutput.removeFirst(terminalOutput.count - 1000)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendCommand() {
|
||||
guard !inputText.isEmpty, connectionStatus == .connected else { return }
|
||||
guard !inputText.isEmpty, state.connectionStatus == .connected else { return }
|
||||
|
||||
let command = inputText
|
||||
inputText = ""
|
||||
|
||||
// Show the command in output
|
||||
terminalOutput.append(TerminalLine(text: "$ \(command)", type: .input))
|
||||
state.appendLine(TerminalLine(text: "$ \(command)", type: .input))
|
||||
|
||||
// Send to WebSocket
|
||||
let message = ["t": "i", "d": command + "\n"]
|
||||
if let data = try? JSONSerialization.data(withJSONObject: message),
|
||||
let jsonString = String(data: data, encoding: .utf8) {
|
||||
webSocketTask?.send(.string(jsonString)) { error in
|
||||
state.webSocketTask?.send(.string(jsonString)) { error in
|
||||
if let error = error {
|
||||
DispatchQueue.main.async {
|
||||
addErrorLine("Send error: \(error.localizedDescription)")
|
||||
Task { @MainActor in
|
||||
state.appendLine(TerminalLine(text: "Send error: \(error.localizedDescription)", type: .error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -598,17 +343,9 @@ struct TerminalView: View {
|
||||
let message = ["t": "r", "c": cols, "r": rows] as [String: Any]
|
||||
if let data = try? JSONSerialization.data(withJSONObject: message),
|
||||
let jsonString = String(data: data, encoding: .utf8) {
|
||||
webSocketTask?.send(.string(jsonString)) { _ in }
|
||||
state.webSocketTask?.send(.string(jsonString)) { _ in }
|
||||
}
|
||||
}
|
||||
|
||||
private func addSystemLine(_ text: String) {
|
||||
terminalOutput.append(TerminalLine(text: text, type: .system))
|
||||
}
|
||||
|
||||
private func addErrorLine(_ text: String) {
|
||||
terminalOutput.append(TerminalLine(text: text, type: .error))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||