fix: ios bugs

This commit is contained in:
Thomas Marchand
2025-12-17 13:56:04 +00:00
parent f49b831701
commit bf15c67c96
25 changed files with 1299 additions and 420 deletions

View File

@@ -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=="],
}
}

View File

@@ -33,6 +33,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.10",
"sharp": "^0.34.5",
"tailwindcss": "^4",
"typescript": "^5"
}

View File

@@ -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

View File

@@ -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 */,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 B

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View 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)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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 {

135
scripts/generate_ios_icons.js Executable file

File diff suppressed because one or more lines are too long

121
scripts/generate_ios_icons.py Executable file

File diff suppressed because one or more lines are too long