Răsfoiți Sursa

refactor(tui): expose theme colors as properties (#37995)

James Long 3 săptămâni în urmă
părinte
comite
c0ba62b972
46 a modificat fișierele cu 740 adăugiri și 749 ștergeri
  1. 1 1
      packages/tui/src/app.tsx
  2. 8 8
      packages/tui/src/component/devtools-sidebar.tsx
  3. 7 7
      packages/tui/src/component/dialog-debug.tsx
  4. 19 19
      packages/tui/src/component/dialog-integration.tsx
  5. 11 11
      packages/tui/src/component/dialog-mcp.tsx
  6. 11 11
      packages/tui/src/component/dialog-move-session.tsx
  7. 14 14
      packages/tui/src/component/dialog-pair.tsx
  8. 10 11
      packages/tui/src/component/dialog-project-copy-name.tsx
  9. 12 10
      packages/tui/src/component/dialog-retry-action.tsx
  10. 7 7
      packages/tui/src/component/dialog-session-delete-failed.tsx
  11. 6 6
      packages/tui/src/component/dialog-skill.tsx
  12. 11 11
      packages/tui/src/component/dialog-status.tsx
  13. 12 12
      packages/tui/src/component/dialog-workspace-file-changes.tsx
  14. 3 3
      packages/tui/src/component/logo.tsx
  15. 3 3
      packages/tui/src/component/plugin-route-missing.tsx
  16. 40 40
      packages/tui/src/component/prompt/index.tsx
  17. 2 2
      packages/tui/src/component/reconnecting.tsx
  18. 1 1
      packages/tui/src/component/spinner.tsx
  19. 2 2
      packages/tui/src/component/startup-loading.tsx
  20. 4 1
      packages/tui/src/context/local.tsx
  21. 6 8
      packages/tui/src/feature-plugins/home/footer.tsx
  22. 5 5
      packages/tui/src/feature-plugins/sidebar/context.tsx
  23. 1 1
      packages/tui/src/feature-plugins/sidebar/footer.tsx
  24. 2 2
      packages/tui/src/feature-plugins/sidebar/lsp.tsx
  25. 11 11
      packages/tui/src/feature-plugins/sidebar/mcp.tsx
  26. 2 2
      packages/tui/src/feature-plugins/system/diff-viewer-ui.tsx
  27. 4 4
      packages/tui/src/feature-plugins/system/scrap.tsx
  28. 9 9
      packages/tui/src/routes/session/composer/index.tsx
  29. 5 3
      packages/tui/src/routes/session/composer/shell-tab.tsx
  30. 16 11
      packages/tui/src/routes/session/composer/subagents-tab.tsx
  31. 10 12
      packages/tui/src/routes/session/footer.tsx
  32. 84 64
      packages/tui/src/routes/session/form.tsx
  33. 131 127
      packages/tui/src/routes/session/index.tsx
  34. 61 52
      packages/tui/src/routes/session/permission.tsx
  35. 5 5
      packages/tui/src/routes/session/sidebar.tsx
  36. 19 13
      packages/tui/src/routes/session/subagent-footer.tsx
  37. 26 126
      packages/tui/src/theme/v2/component.ts
  38. 5 5
      packages/tui/src/ui/dialog-alert.tsx
  39. 5 5
      packages/tui/src/ui/dialog-confirm.tsx
  40. 79 27
      packages/tui/src/ui/dialog-export-options.tsx
  41. 10 5
      packages/tui/src/ui/dialog-export-result.tsx
  42. 5 5
      packages/tui/src/ui/dialog-help.tsx
  43. 10 10
      packages/tui/src/ui/dialog-prompt.tsx
  44. 1 1
      packages/tui/src/ui/dialog.tsx
  45. 4 4
      packages/tui/src/ui/toast.tsx
  46. 40 52
      packages/tui/test/theme/v2/component.test.ts

+ 1 - 1
packages/tui/src/app.tsx

@@ -1088,7 +1088,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
       width={dimensions().width}
       height={dimensions().height}
       flexDirection="column"
-      backgroundColor={themeV2.background()}
+      backgroundColor={themeV2.background.default}
       onMouseDown={(evt) => {
         if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
         if (evt.button !== MouseButton.RIGHT) return

+ 8 - 8
packages/tui/src/component/devtools-sidebar.tsx

@@ -17,26 +17,26 @@ export function DevToolsSidebar() {
       paddingBottom={1}
       paddingLeft={2}
       paddingRight={2}
-      backgroundColor={themeV2.background()}
+      backgroundColor={themeV2.background.default}
     >
       <box flexShrink={0} marginBottom={1}>
         <box marginBottom={1}>
-          <text fg={themeV2.text.action()} attributes={TextAttributes.BOLD}>
+          <text fg={themeV2.text.action.primary.default} attributes={TextAttributes.BOLD}>
             Theme
           </text>
         </box>
         <box flexDirection="row">
-          <text fg={themeV2.text.subdued()}>Mode</text>
+          <text fg={themeV2.text.subdued}>Mode</text>
           <box flexGrow={1} />
           <box
             paddingLeft={1}
             paddingRight={1}
-            backgroundColor={modeHovered() && canSwitchMode() ? themeV2.background.action("hovered") : undefined}
+            backgroundColor={modeHovered() && canSwitchMode() ? themeV2.background.action.primary.hovered : undefined}
             onMouseOver={() => setModeHovered(canSwitchMode())}
             onMouseOut={() => setModeHovered(false)}
             onMouseUp={canSwitchMode() ? () => setMode(nextMode()) : undefined}
           >
-            <text fg={canSwitchMode() ? themeV2.text() : themeV2.text.subdued()}>{mode()}</text>
+            <text fg={canSwitchMode() ? themeV2.text.default : themeV2.text.subdued}>{mode()}</text>
           </box>
         </box>
       </box>
@@ -44,16 +44,16 @@ export function DevToolsSidebar() {
         {(group) => (
           <box flexShrink={0} marginBottom={1}>
             <box marginBottom={1}>
-              <text fg={themeV2.text.action()} attributes={TextAttributes.BOLD}>
+              <text fg={themeV2.text.action.primary.default} attributes={TextAttributes.BOLD}>
                 {group.title}
               </text>
             </box>
             <For each={group.entries}>
               {(entry) => (
                 <box flexDirection="row">
-                  <text fg={themeV2.text.subdued()}>{entry.key}</text>
+                  <text fg={themeV2.text.subdued}>{entry.key}</text>
                   <box flexGrow={1} />
-                  <text fg={themeV2.text()}>{String(entry.value)}</text>
+                  <text fg={themeV2.text.default}>{String(entry.value)}</text>
                 </box>
               )}
             </For>

+ 7 - 7
packages/tui/src/component/dialog-debug.tsx

@@ -54,10 +54,10 @@ export function DialogDebug() {
   return (
     <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
       <box flexDirection="row" justifyContent="space-between">
-        <text fg={themeV2.text()} attributes={TextAttributes.BOLD}>
+        <text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
           Debug
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
+        <text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
           esc
         </text>
       </box>
@@ -67,10 +67,10 @@ export function DialogDebug() {
         <For each={entries()}>
           {(entry) => (
             <box flexDirection="row" gap={1}>
-              <text flexShrink={0} fg={themeV2.text.subdued()}>
+              <text flexShrink={0} fg={themeV2.text.subdued}>
                 {entry.label.padEnd(10)}
               </text>
-              <text fg={themeV2.text()} wrapMode="word">
+              <text fg={themeV2.text.default} wrapMode="word">
                 {entry.value}
               </text>
             </box>
@@ -78,12 +78,12 @@ export function DialogDebug() {
         </For>
       </box>
       <box flexDirection="row" justifyContent="space-between">
-        <text fg={themeV2.text.subdued()}>Share this when reporting an issue.</text>
+        <text fg={themeV2.text.subdued}>Share this when reporting an issue.</text>
         <text onMouseUp={copy}>
-          <span style={{ fg: copied() ? themeV2.text.feedback.success() : themeV2.text() }}>
+          <span style={{ fg: copied() ? themeV2.text.feedback.success.default : themeV2.text.default }}>
             <b>{copied() ? "✓ copied" : "copy"}</b>{" "}
           </span>
-          <span style={{ fg: themeV2.text.subdued() }}>enter</span>
+          <span style={{ fg: themeV2.text.subdued }}>enter</span>
         </text>
       </box>
     </box>

+ 19 - 19
packages/tui/src/component/dialog-integration.tsx

@@ -74,7 +74,7 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
         footer: connectionSummary(integration) || undefined,
         category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
         disabled: methods.length === 0,
-        gutter: connected ? () => <text fg={themeV2.text.feedback.success()}>✓</text> : undefined,
+        gutter: connected ? () => <text fg={themeV2.text.feedback.success.default}>✓</text> : undefined,
         onSelect: () =>
           credentialConnections(integration).length
             ? manageConnections(integration, methods, dialog, props.onConnected)
@@ -89,12 +89,12 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
       options={options()}
       emptyView={
         <box paddingLeft={4} paddingRight={4} paddingTop={1}>
-          <text fg={themeV2.text.subdued()}>No integrations available</text>
+          <text fg={themeV2.text.subdued}>No integrations available</text>
         </box>
       }
       noMatchView={
         <box paddingLeft={4} paddingRight={4} paddingTop={1}>
-          <text fg={themeV2.text.subdued()}>No integrations found</text>
+          <text fg={themeV2.text.subdued}>No integrations found</text>
         </box>
       }
     />
@@ -295,24 +295,24 @@ function CommandView(props: { title: string; output: string; message: string })
   return (
     <box gap={1} paddingBottom={1}>
       <box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
-        <text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
+        <text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
           {props.title}
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
+        <text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
           esc close
         </text>
       </box>
       <box
-        backgroundColor={overlayTheme.background()}
+        backgroundColor={overlayTheme.background.default}
         paddingLeft={2}
         paddingRight={2}
         paddingTop={1}
         paddingBottom={1}
       >
-        <text fg={overlayTheme.text()}>{props.output.trim()}</text>
+        <text fg={overlayTheme.text.default}>{props.output.trim()}</text>
       </box>
       <box paddingLeft={2} paddingRight={2}>
-        <text fg={themeV2.text.subdued()}>{props.message}</text>
+        <text fg={themeV2.text.subdued}>{props.message}</text>
       </box>
     </box>
   )
@@ -346,7 +346,7 @@ function KeyMethod(props: {
           .catch((cause) => setError(message(cause)))
       }}
       description={() => (
-        <Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error()}>{value()}</text>}</Show>
+        <Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
       )}
     />
   )
@@ -536,9 +536,9 @@ function OAuthCode(props: {
       }}
       description={() => (
         <box gap={1}>
-          <text fg={themeV2.text.subdued()}>{props.attempt.instructions}</text>
-          <Link href={props.attempt.url} fg={themeV2.markdown.link()} />
-          <Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error()}>{value()}</text>}</Show>
+          <text fg={themeV2.text.subdued}>{props.attempt.instructions}</text>
+          <Link href={props.attempt.url} fg={themeV2.markdown.link} />
+          <Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
         </box>
       )}
     />
@@ -551,27 +551,27 @@ function OAuthView(props: { title: string; url?: string; instructions?: string;
   return (
     <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
       <box flexDirection="row" justifyContent="space-between">
-        <text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
+        <text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
           {props.title}
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
+        <text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
           esc
         </text>
       </box>
       <Show when={props.url}>
         {(url) => (
           <box gap={1}>
-            <Link href={url()} fg={themeV2.markdown.link()} />
+            <Link href={url()} fg={themeV2.markdown.link} />
             <Show when={props.instructions}>
-              {(instructions) => <text fg={themeV2.text.subdued()}>{instructions()}</text>}
+              {(instructions) => <text fg={themeV2.text.subdued}>{instructions()}</text>}
             </Show>
           </box>
         )}
       </Show>
-      <text fg={themeV2.text.subdued()}>{props.message}</text>
+      <text fg={themeV2.text.subdued}>{props.message}</text>
       <Show when={props.copy}>
-        <text fg={themeV2.text()}>
-          c <span style={{ fg: themeV2.text.subdued() }}>copy</span>
+        <text fg={themeV2.text.default}>
+          c <span style={{ fg: themeV2.text.subdued }}>copy</span>
         </text>
       </Show>
     </box>

+ 11 - 11
packages/tui/src/component/dialog-mcp.tsx

@@ -21,11 +21,11 @@ function statusError(status: McpServer["status"]) {
 
 function Status(props: { enabled: boolean; loading: boolean }) {
   const { themeV2 } = useTheme().contextual("elevated")
-  if (props.loading) return <span style={{ fg: themeV2.text.subdued() }}>⋯ Loading</span>
+  if (props.loading) return <span style={{ fg: themeV2.text.subdued }}>⋯ Loading</span>
   if (props.enabled) {
-    return <span style={{ fg: themeV2.text.feedback.success(), attributes: TextAttributes.BOLD }}>✓ Enabled</span>
+    return <span style={{ fg: themeV2.text.feedback.success.default, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
   }
-  return <span style={{ fg: themeV2.text.subdued() }}>○ Disabled</span>
+  return <span style={{ fg: themeV2.text.subdued }}>○ Disabled</span>
 }
 
 export function DialogMcp() {
@@ -110,7 +110,7 @@ export function DialogMcp() {
             ]}
             footer={
               <Show when={focusedError()}>
-                <text fg={themeV2.text.subdued()}>enter to view error</text>
+                <text fg={themeV2.text.subdued}>enter to view error</text>
               </Show>
             }
           />
@@ -171,16 +171,16 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
   return (
     <box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
       <box flexDirection="row" justifyContent="space-between">
-        <text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
+        <text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
           MCP server: {props.server.name}
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={props.onBack}>
+        <text fg={themeV2.text.subdued} onMouseUp={props.onBack}>
           esc back
         </text>
       </box>
-      <text fg={themeV2.text.feedback.error()}>✗ Failed</text>
+      <text fg={themeV2.text.feedback.error.default}>✗ Failed</text>
       <box
-        backgroundColor={overlayTheme.background()}
+        backgroundColor={overlayTheme.background.default}
         paddingLeft={2}
         paddingRight={2}
         paddingTop={1}
@@ -192,14 +192,14 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
           scrollbarOptions={{ visible: false }}
           scrollAcceleration={getScrollAcceleration(config)}
         >
-          <text fg={overlayTheme.text()} wrapMode="word">
+          <text fg={overlayTheme.text.default} wrapMode="word">
             {error()}
           </text>
         </scrollbox>
       </box>
       <box flexDirection="row" justifyContent="space-between">
-        <text fg={themeV2.text.subdued()}>↑↓ scroll</text>
-        <text fg={themeV2.text.subdued()} onMouseUp={copy}>
+        <text fg={themeV2.text.subdued}>↑↓ scroll</text>
+        <text fg={themeV2.text.subdued} onMouseUp={copy}>
           {copied() ? "✓ copied" : "c copy details"}
         </text>
       </box>

+ 11 - 11
packages/tui/src/component/dialog-move-session.tsx

@@ -172,18 +172,18 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
       return {
         title,
         titleView: isRemoving ? (
-          <span style={{ fg: themeV2.text.feedback.error() }}>Deleting {item.location}</span>
+          <span style={{ fg: themeV2.text.feedback.error.default }}>Deleting {item.location}</span>
         ) : deleting ? (
-          <span style={{ fg: themeV2.text.action.destructive() }}>
+          <span style={{ fg: themeV2.text.action.destructive.default }}>
             Press {shortcuts.get("dialog.move_session.delete")} again to confirm
           </span>
         ) : suffix ? (
           <>
             {visible.slice(0, split)}
-            <span style={{ fg: themeV2.text.subdued() }}>{visible.slice(split)}</span>
+            <span style={{ fg: themeV2.text.subdued }}>{visible.slice(split)}</span>
           </>
         ) : undefined,
-        bg: deleting ? themeV2.background.action.destructive() : undefined,
+        bg: deleting ? themeV2.background.action.destructive.default : undefined,
         value: {
           type: "directory",
           directory: item.location,
@@ -316,7 +316,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
         title="Move session"
         titleView={
           <box flexDirection="row" gap={1}>
-            <text fg={themeV2.text()} attributes={TextAttributes.BOLD}>
+            <text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
               Move session
             </text>
             <Show when={working() || directories.loading || loadedProject.loading}>
@@ -329,25 +329,25 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
         emptyView={
           showError() ? (
             <box paddingLeft={4} paddingRight={4} paddingTop={1}>
-              <text fg={themeV2.text.feedback.error()} attributes={TextAttributes.BOLD}>
+              <text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
                 Could not load project directories
               </text>
-              <text fg={themeV2.text.subdued()}>{errorMessage(loadError())}</text>
-              <text fg={themeV2.text.subdued()}>Close and reopen Move session to try again.</text>
+              <text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
+              <text fg={themeV2.text.subdued}>Close and reopen Move session to try again.</text>
             </box>
           ) : directories.loading || loadedProject.loading ? (
             <box paddingLeft={4} paddingRight={4} paddingTop={1}>
-              <text fg={themeV2.text.subdued()}>Loading project directories…</text>
+              <text fg={themeV2.text.subdued}>Loading project directories…</text>
             </box>
           ) : (
             <box paddingLeft={4} paddingRight={4} paddingTop={1}>
-              <text fg={themeV2.text.subdued()}>No project directories available</text>
+              <text fg={themeV2.text.subdued}>No project directories available</text>
             </box>
           )
         }
         noMatchView={
           <box paddingLeft={4} paddingRight={4} paddingTop={1}>
-            <text fg={themeV2.text.subdued()}>No project directories found</text>
+            <text fg={themeV2.text.subdued}>No project directories found</text>
           </box>
         }
         locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}

+ 14 - 14
packages/tui/src/component/dialog-pair.tsx

@@ -47,17 +47,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
       <box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
         <box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
           <box>
-            <text fg={themeV2.text.subdued()}>URLs</text>
-            <For each={value.urls}>{(url) => <text fg={themeV2.text()}>{url}</text>}</For>
+            <text fg={themeV2.text.subdued}>URLs</text>
+            <For each={value.urls}>{(url) => <text fg={themeV2.text.default}>{url}</text>}</For>
           </box>
           <box>
-            <text fg={themeV2.text.subdued()}>Username</text>
-            <text fg={themeV2.text()}>{value.username}</text>
+            <text fg={themeV2.text.subdued}>Username</text>
+            <text fg={themeV2.text.default}>{value.username}</text>
           </box>
           <box>
-            <text fg={themeV2.text.subdued()}>Password</text>
+            <text fg={themeV2.text.subdued}>Password</text>
             <text
-              fg={passwordHover() ? themeV2.text() : themeV2.text.subdued()}
+              fg={passwordHover() ? themeV2.text.default : themeV2.text.subdued}
               wrapMode="word"
               onMouseOver={() => setPasswordHover(true)}
               onMouseOut={() => setPasswordHover(false)}
@@ -67,7 +67,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
             </text>
           </box>
           <Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
-            <text fg={themeV2.text.subdued()} wrapMode="word">
+            <text fg={themeV2.text.subdued} wrapMode="word">
               Run `opencode service set hostname 0.0.0.0` to access the service remotely.
             </text>
           </Show>
@@ -78,7 +78,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
           flexShrink={0}
           alignItems={horizontal() ? "flex-end" : "center"}
         >
-          <text fg={themeV2.text()}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
+          <text fg={themeV2.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
         </box>
       </box>
     )
@@ -87,17 +87,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
   return (
     <box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
       <box flexDirection="row" justifyContent="space-between">
-        <text fg={themeV2.text()} attributes={TextAttributes.BOLD}>
+        <text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
           Pair
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
+        <text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
           esc
         </text>
       </box>
       <Show
         when={loadError()}
         fallback={
-          <Show when={info()} fallback={<text fg={themeV2.text.subdued()}>Loading server information…</text>}>
+          <Show when={info()} fallback={<text fg={themeV2.text.subdued}>Loading server information…</text>}>
             <Show
               when={dimensions().height >= 36}
               fallback={
@@ -116,11 +116,11 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
       >
         {(error) => (
           <box>
-            <text fg={themeV2.text.feedback.error()} attributes={TextAttributes.BOLD}>
+            <text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
               Could not load server information
             </text>
-            <text fg={themeV2.text.subdued()}>{errorMessage(error())}</text>
-            <text fg={themeV2.text.subdued()}>Close and reopen Pair to try again.</text>
+            <text fg={themeV2.text.subdued}>{errorMessage(error())}</text>
+            <text fg={themeV2.text.subdued}>Close and reopen Pair to try again.</text>
           </box>
         )}
       </Show>

+ 10 - 11
packages/tui/src/component/dialog-project-copy-name.tsx

@@ -47,10 +47,10 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
   return (
     <box paddingLeft={2} paddingRight={2} gap={1}>
       <box flexDirection="row" justifyContent="space-between">
-        <text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
+        <text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
           Name project copy
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
+        <text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
           esc
         </text>
       </box>
@@ -61,18 +61,17 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
         }}
         onSubmit={confirm}
         placeholder="Project copy name"
-        placeholderColor={themeV2.text.subdued()}
-        textColor={themeV2.text.formfield()}
-        focusedTextColor={themeV2.text.formfield()}
-        cursorColor={themeV2.text.formfield()}
+        placeholderColor={themeV2.text.subdued}
+        textColor={themeV2.text.formfield.default}
+        focusedTextColor={themeV2.text.formfield.default}
+        cursorColor={themeV2.text.formfield.default}
       />
       <box paddingBottom={1} flexDirection="row" gap={2}>
-        <text fg={themeV2.text()}>
-          enter <span style={{ fg: themeV2.text.subdued() }}>submit</span>
+        <text fg={themeV2.text.default}>
+          enter <span style={{ fg: themeV2.text.subdued }}>submit</span>
         </text>
-        <text fg={themeV2.text()}>
-          {shortcuts.get("dialog.project_copy.generate")}{" "}
-          <span style={{ fg: themeV2.text.subdued() }}>generate one</span>
+        <text fg={themeV2.text.default}>
+          {shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: themeV2.text.subdued }}>generate one</span>
         </text>
       </box>
     </box>

+ 12 - 10
packages/tui/src/component/dialog-retry-action.tsx

@@ -40,7 +40,7 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
   const dialog = useDialog()
   const { themeV2 } = useTheme().contextual("elevated")
   const showGoTreatment = () => props.link === GO_URL
-  const textBg = () => (showGoTreatment() ? panelOverlay(themeV2.background()) : undefined)
+  const textBg = () => (showGoTreatment() ? panelOverlay(themeV2.background.default) : undefined)
   const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
 
   Keymap.createLayer(() => ({
@@ -85,26 +85,26 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
       ) : null}
       <box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
         <box flexDirection="row" justifyContent="space-between">
-          <text attributes={TextAttributes.BOLD} fg={themeV2.text()} bg={textBg()}>
+          <text attributes={TextAttributes.BOLD} fg={themeV2.text.default} bg={textBg()}>
             {props.title}
           </text>
-          <text fg={themeV2.text.subdued()} bg={textBg()} onMouseUp={() => dialog.clear()}>
+          <text fg={themeV2.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
             esc
           </text>
         </box>
         <box gap={0}>
-          <text fg={themeV2.text.subdued()} bg={textBg()}>
+          <text fg={themeV2.text.subdued} bg={textBg()}>
             {props.message}
           </text>
         </box>
         {props.link ? (
           showGoTreatment() ? (
             <box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
-              <Link href={props.link} fg={themeV2.markdown.link()} bg={textBg()} wrapMode="none" />
+              <Link href={props.link} fg={themeV2.markdown.link} bg={textBg()} wrapMode="none" />
             </box>
           ) : (
             <box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
-              <Link href={props.link} fg={themeV2.markdown.link()} wrapMode="none" />
+              <Link href={props.link} fg={themeV2.markdown.link} wrapMode="none" />
             </box>
           )
         ) : (
@@ -115,13 +115,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
             paddingLeft={2}
             paddingRight={2}
             backgroundColor={
-              selected() === "dismiss" ? themeV2.background.action("focused") : RGBA.fromInts(0, 0, 0, 0)
+              selected() === "dismiss" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
             }
             onMouseOver={() => setSelected("dismiss")}
             onMouseUp={() => dismiss(props, dialog)}
           >
             <text
-              fg={selected() === "dismiss" ? themeV2.text.action("focused") : themeV2.text.subdued()}
+              fg={selected() === "dismiss" ? themeV2.text.action.primary.focused : themeV2.text.subdued}
               bg={selected() === "dismiss" ? undefined : textBg()}
               attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
             >
@@ -131,12 +131,14 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
           <box
             paddingLeft={2}
             paddingRight={2}
-            backgroundColor={selected() === "action" ? themeV2.background.action("focused") : RGBA.fromInts(0, 0, 0, 0)}
+            backgroundColor={
+              selected() === "action" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
+            }
             onMouseOver={() => setSelected("action")}
             onMouseUp={() => runAction(props, dialog)}
           >
             <text
-              fg={selected() === "action" ? themeV2.text.action("focused") : themeV2.text()}
+              fg={selected() === "action" ? themeV2.text.action.primary.focused : themeV2.text.default}
               bg={selected() === "action" ? undefined : textBg()}
               attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
             >

+ 7 - 7
packages/tui/src/component/dialog-session-delete-failed.tsx

@@ -64,17 +64,17 @@ export function DialogSessionDeleteFailed(props: {
   return (
     <box paddingLeft={2} paddingRight={2} gap={1}>
       <box flexDirection="row" justifyContent="space-between">
-        <text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
+        <text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
           Failed to Delete Session
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
+        <text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
           esc
         </text>
       </box>
-      <text fg={themeV2.text.subdued()} wrapMode="word">
+      <text fg={themeV2.text.subdued} wrapMode="word">
         {`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
       </text>
-      <text fg={themeV2.text.subdued()} wrapMode="word">
+      <text fg={themeV2.text.subdued} wrapMode="word">
         Choose how you want to recover this broken workspace session.
       </text>
       <box flexDirection="column" paddingBottom={1} gap={1}>
@@ -86,7 +86,7 @@ export function DialogSessionDeleteFailed(props: {
               paddingRight={1}
               paddingTop={1}
               paddingBottom={1}
-              backgroundColor={item.id === store.active ? themeV2.background.action("focused") : undefined}
+              backgroundColor={item.id === store.active ? themeV2.background.action.primary.focused : undefined}
               onMouseUp={() => {
                 setStore("active", item.id)
                 void confirm()
@@ -94,12 +94,12 @@ export function DialogSessionDeleteFailed(props: {
             >
               <text
                 attributes={TextAttributes.BOLD}
-                fg={item.id === store.active ? themeV2.text.action("focused") : themeV2.text()}
+                fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.default}
               >
                 {item.title}
               </text>
               <text
-                fg={item.id === store.active ? themeV2.text.action("focused") : themeV2.text.subdued()}
+                fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}
                 wrapMode="word"
               >
                 {item.description}

+ 6 - 6
packages/tui/src/component/dialog-skill.tsx

@@ -63,29 +63,29 @@ export function DialogSkill(props: DialogSkillProps) {
         <Switch
           fallback={
             <box paddingLeft={4} paddingRight={4} paddingTop={1}>
-              <text fg={themeV2.text.subdued()}>No skills available</text>
+              <text fg={themeV2.text.subdued}>No skills available</text>
             </box>
           }
         >
           <Match when={showError()}>
             <box paddingLeft={4} paddingRight={4} paddingTop={1}>
-              <text fg={themeV2.text.feedback.error()} attributes={TextAttributes.BOLD}>
+              <text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
                 Could not load skills
               </text>
-              <text fg={themeV2.text.subdued()}>{errorMessage(loadError())}</text>
-              <text fg={themeV2.text.subdued()}>Close and reopen Skills to try again.</text>
+              <text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
+              <text fg={themeV2.text.subdued}>Close and reopen Skills to try again.</text>
             </box>
           </Match>
           <Match when={skills.loading}>
             <box paddingLeft={4} paddingRight={4} paddingTop={1}>
-              <text fg={themeV2.text.subdued()}>Loading skills…</text>
+              <text fg={themeV2.text.subdued}>Loading skills…</text>
             </box>
           </Match>
         </Switch>
       }
       noMatchView={
         <box paddingLeft={4} paddingRight={4} paddingTop={1}>
-          <text fg={themeV2.text.subdued()}>No skills found</text>
+          <text fg={themeV2.text.subdued}>No skills found</text>
         </box>
       }
     />

+ 11 - 11
packages/tui/src/component/dialog-status.tsx

@@ -13,25 +13,25 @@ export function DialogStatus() {
 
   const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
   const color = (status: string) => {
-    if (status === "connected") return themeV2.text.feedback.success()
-    if (status === "failed") return themeV2.text.feedback.error()
-    if (status === "needs_auth") return themeV2.text.feedback.warning()
-    if (status === "needs_client_registration") return themeV2.text.feedback.error()
-    return themeV2.text.subdued()
+    if (status === "connected") return themeV2.text.feedback.success.default
+    if (status === "failed") return themeV2.text.feedback.error.default
+    if (status === "needs_auth") return themeV2.text.feedback.warning.default
+    if (status === "needs_client_registration") return themeV2.text.feedback.error.default
+    return themeV2.text.subdued
   }
   return (
     <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
       <box flexDirection="row" justifyContent="space-between">
-        <text fg={themeV2.text()} attributes={TextAttributes.BOLD}>
+        <text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
           Status
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
+        <text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
           esc
         </text>
       </box>
-      <Show when={mcp().length > 0} fallback={<text fg={themeV2.text()}>No MCP servers</text>}>
+      <Show when={mcp().length > 0} fallback={<text fg={themeV2.text.default}>No MCP servers</text>}>
         <box>
-          <text fg={themeV2.text()}>
+          <text fg={themeV2.text.default}>
             {mcp().length} MCP server{mcp().length === 1 ? "" : "s"}
           </text>
           <For each={mcp()}>
@@ -40,9 +40,9 @@ export function DialogStatus() {
                 <text flexShrink={0} style={{ fg: color(item.status.status) }}>
                 </text>
-                <text fg={themeV2.text()} wrapMode="word">
+                <text fg={themeV2.text.default} wrapMode="word">
                   <b>{item.name}</b>{" "}
-                  <span style={{ fg: themeV2.text.subdued() }}>
+                  <span style={{ fg: themeV2.text.subdued }}>
                     <Switch fallback={item.status.status}>
                       <Match when={item.status.status === "connected"}>Connected</Match>
                       <Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>

+ 12 - 12
packages/tui/src/component/dialog-workspace-file-changes.tsx

@@ -72,21 +72,21 @@ export function DialogWorkspaceFileChanges(props: {
   return (
     <box gap={1}>
       <box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
-        <text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
+        <text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
           {props.title ?? "File Changes Found"}
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
+        <text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
           esc
         </text>
       </box>
       <box paddingLeft={2} paddingRight={2}>
-        <text fg={themeV2.text.subdued()} wrapMode="word">
+        <text fg={themeV2.text.subdued} wrapMode="word">
           {props.message ?? "Do you want to move these changes with the session?"}
         </text>
       </box>
       <scrollbox
         height={height()}
-        backgroundColor={overlayTheme.background()}
+        backgroundColor={overlayTheme.background.default}
         scrollbarOptions={{ visible: false }}
         scrollAcceleration={scrollAcceleration()}
       >
@@ -95,18 +95,16 @@ export function DialogWorkspaceFileChanges(props: {
             <box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
               <box flexDirection="row" minWidth={0} flexShrink={1}>
                 <box width={2} flexShrink={0}>
-                  <text fg={overlayTheme.text.subdued()}>{statusLabel(item.status)}</text>
+                  <text fg={overlayTheme.text.subdued}>{statusLabel(item.status)}</text>
                 </box>
-                <FilePath value={item.file} maxWidth={fileNameWidth()} fg={overlayTheme.text.subdued()} />
+                <FilePath value={item.file} maxWidth={fileNameWidth()} fg={overlayTheme.text.subdued} />
               </box>
               <box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
                 <text>
                   {" "}
-                  {item.additions ? (
-                    <span style={{ fg: overlayTheme.diff.text.added() }}>+{item.additions}</span>
-                  ) : null}
+                  {item.additions ? <span style={{ fg: overlayTheme.diff.text.added }}>+{item.additions}</span> : null}
                   {item.deletions ? (
-                    <span style={{ fg: overlayTheme.diff.text.removed() }}> -{item.deletions}</span>
+                    <span style={{ fg: overlayTheme.diff.text.removed }}> -{item.deletions}</span>
                   ) : null}
                 </text>
               </box>
@@ -120,14 +118,16 @@ export function DialogWorkspaceFileChanges(props: {
             <box
               paddingLeft={2}
               paddingRight={2}
-              backgroundColor={item === store.active ? themeV2.background.action("focused") : undefined}
+              backgroundColor={item === store.active ? themeV2.background.action.primary.focused : undefined}
               onMouseUp={() => {
                 setStore("active", item)
                 props.onSelect(item)
                 dialog.clear()
               }}
             >
-              <text fg={item === store.active ? themeV2.text.action("focused") : themeV2.text.subdued()}>{item}</text>
+              <text fg={item === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
+                {item}
+              </text>
             </box>
           )}
         </For>

+ 3 - 3
packages/tui/src/component/logo.tsx

@@ -8,7 +8,7 @@ export function Logo() {
   const { themeV2 } = useTheme()
 
   const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
-    const shadow = tint(themeV2.background(), fg, 0.25)
+    const shadow = tint(themeV2.background.default, fg, 0.25)
     const attrs = bold ? TextAttributes.BOLD : undefined
     return Array.from(line).map((char) => {
       if (char === "_") {
@@ -52,8 +52,8 @@ export function Logo() {
       <For each={logo.left}>
         {(line, index) => (
           <box flexDirection="row" gap={1}>
-            <box flexDirection="row">{renderLine(line, themeV2.text.subdued(), false)}</box>
-            <box flexDirection="row">{renderLine(logo.right[index()], themeV2.text(), true)}</box>
+            <box flexDirection="row">{renderLine(line, themeV2.text.subdued, false)}</box>
+            <box flexDirection="row">{renderLine(logo.right[index()], themeV2.text.default, true)}</box>
           </box>
         )}
       </For>

+ 3 - 3
packages/tui/src/component/plugin-route-missing.tsx

@@ -5,16 +5,16 @@ export function PluginRouteMissing(props: { id: string; name: string; onHome: ()
 
   return (
     <box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
-      <text fg={themeV2.text.feedback.warning()}>
+      <text fg={themeV2.text.feedback.warning.default}>
         Unknown plugin route: {props.id}/{props.name}
       </text>
       <box
         onMouseUp={props.onHome}
-        backgroundColor={themeV2.background.action("hovered")}
+        backgroundColor={themeV2.background.action.primary.hovered}
         paddingLeft={1}
         paddingRight={1}
       >
-        <text fg={themeV2.text.action("hovered")}>go home</text>
+        <text fg={themeV2.text.action.primary.hovered}>go home</text>
       </box>
     </box>
   )

+ 40 - 40
packages/tui/src/component/prompt/index.tsx

@@ -297,8 +297,8 @@ export function Prompt(props: PromptProps) {
 
   createEffect(() => {
     if (!input || input.isDestroyed) return
-    if (props.disabled) input.cursorColor = themeV2.background.surface.offset()
-    if (!props.disabled) input.cursorColor = themeV2.text()
+    if (props.disabled) input.cursorColor = themeV2.background.surface.offset
+    if (!props.disabled) input.cursorColor = themeV2.text.default
   })
 
   const usage = createMemo(() => {
@@ -1304,10 +1304,10 @@ export function Prompt(props: PromptProps) {
   }
 
   const highlight = createMemo(() => {
-    if (leader()) return themeV2.border()
-    if (store.mode === "shell") return themeV2.background.action()
+    if (leader()) return themeV2.border.default
+    if (store.mode === "shell") return themeV2.background.action.primary.default
     const agent = local.agent.current()
-    if (!agent) return themeV2.border()
+    if (!agent) return themeV2.border.default
     return local.agent.color(agent.id)
   })
 
@@ -1324,7 +1324,7 @@ export function Prompt(props: PromptProps) {
     () => !!local.agent.current() && store.mode === "normal" && showVariant(),
     animationsEnabled,
   )
-  const borderHighlight = createMemo(() => tint(themeV2.border(), highlight(), agentMetaAlpha()))
+  const borderHighlight = createMemo(() => tint(themeV2.border.default, highlight(), agentMetaAlpha()))
 
   const placeholderText = createMemo(() => {
     if (props.showPlaceholder === false) return undefined
@@ -1344,7 +1344,7 @@ export function Prompt(props: PromptProps) {
 
   const spinnerDef = createMemo(() => {
     const agent = status() === "running" ? local.agent.current() : local.agent.current()
-    const color = agent ? local.agent.color(agent.id) : themeV2.border()
+    const color = agent ? local.agent.color(agent.id) : themeV2.border.default
     return {
       frames: createFrames({
         color,
@@ -1364,7 +1364,7 @@ export function Prompt(props: PromptProps) {
   })
   const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
 
-  const promptBg = createMemo(() => themeV2.raise(themeV2.background.surface.offset()))
+  const promptBg = createMemo(() => themeV2.raise(themeV2.background.surface.offset))
 
   return (
     <>
@@ -1390,9 +1390,9 @@ export function Prompt(props: PromptProps) {
             <textarea
               width="100%"
               placeholder={placeholderText()}
-              placeholderColor={themeV2.text.subdued()}
-              textColor={leader() ? themeV2.text.subdued() : themeV2.text()}
-              focusedTextColor={leader() ? themeV2.text.subdued() : themeV2.text()}
+              placeholderColor={themeV2.text.subdued}
+              textColor={leader() ? themeV2.text.subdued : themeV2.text.default}
+              focusedTextColor={leader() ? themeV2.text.subdued : themeV2.text.default}
               minHeight={1}
               maxHeight={maxHeight()}
               onContentChange={() => {
@@ -1452,7 +1452,7 @@ export function Prompt(props: PromptProps) {
                 setTimeout(() => {
                   // setTimeout is a workaround and needs to be addressed properly
                   if (!input || input.isDestroyed) return
-                  input.cursorColor = themeV2.text()
+                  input.cursorColor = themeV2.text.default
                 }, 0)
               }}
               onMouseDown={(r: MouseEvent) => {
@@ -1460,7 +1460,7 @@ export function Prompt(props: PromptProps) {
                 r.target?.focus()
               }}
               focusedBackgroundColor="transparent"
-              cursorColor={props.disabled ? themeV2.background.surface.offset() : themeV2.text()}
+              cursorColor={props.disabled ? themeV2.background.surface.offset : themeV2.text.default}
               syntaxStyle={syntax()}
             />
             <box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1472,24 +1472,24 @@ export function Prompt(props: PromptProps) {
                         {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().id)}
                       </text>
                       <Show when={store.mode === "normal" && local.permission.mode === "auto"}>
-                        <text fg={fadeColor(themeV2.text.subdued(), agentMetaAlpha())}>auto</text>
+                        <text fg={fadeColor(themeV2.text.subdued, agentMetaAlpha())}>auto</text>
                       </Show>
                       <Show when={store.mode === "normal"}>
                         <box flexDirection="row" gap={1}>
-                          <text fg={fadeColor(themeV2.text.subdued(), modelMetaAlpha())}>·</text>
+                          <text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>·</text>
                           <text
                             flexShrink={0}
-                            fg={fadeColor(leader() ? themeV2.text.subdued() : themeV2.text(), modelMetaAlpha())}
+                            fg={fadeColor(leader() ? themeV2.text.subdued : themeV2.text.default, modelMetaAlpha())}
                           >
                             {local.model.parsed().model}
                           </text>
-                          <text fg={fadeColor(themeV2.text.subdued(), modelMetaAlpha())}>{currentProviderLabel()}</text>
+                          <text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
                           <Show when={showVariant()}>
-                            <text fg={fadeColor(themeV2.text.subdued(), variantMetaAlpha())}>·</text>
+                            <text fg={fadeColor(themeV2.text.subdued, variantMetaAlpha())}>·</text>
                             <text>
                               <span
                                 style={{
-                                  fg: fadeColor(themeV2.text.feedback.warning(), variantMetaAlpha()),
+                                  fg: fadeColor(themeV2.text.feedback.warning.default, variantMetaAlpha()),
                                   bold: true,
                                 }}
                               >
@@ -1543,12 +1543,12 @@ export function Prompt(props: PromptProps) {
               <Match when={status() === "running"}>
                 <box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
                   <box marginLeft={1}>
-                    <Show when={config.animations ?? true} fallback={<text fg={themeV2.text.subdued()}>[⋯]</text>}>
+                    <Show when={config.animations ?? true} fallback={<text fg={themeV2.text.subdued}>[⋯]</text>}>
                       <spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
                     </Show>
                   </box>
                   <text
-                    fg={store.interrupt > 0 ? themeV2.background.action() : themeV2.text()}
+                    fg={store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.default}
                     wrapMode="none"
                     truncate
                     flexShrink={1}
@@ -1556,7 +1556,7 @@ export function Prompt(props: PromptProps) {
                     esc{" "}
                     <span
                       style={{
-                        fg: store.interrupt > 0 ? themeV2.background.action() : themeV2.text.subdued(),
+                        fg: store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.subdued,
                       }}
                     >
                       {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
@@ -1567,16 +1567,16 @@ export function Prompt(props: PromptProps) {
               <Match when={move.progress()}>
                 {(progress) => (
                   <box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
-                    <Spinner color={themeV2.hue.accent(500)}>
+                    <Spinner color={themeV2.hue.accent[500]}>
                       {progress()}
-                      <span style={{ fg: themeV2.text.subdued() }}>{".".repeat(move.creatingDots())}</span>
+                      <span style={{ fg: themeV2.text.subdued }}>{".".repeat(move.creatingDots())}</span>
                     </Spinner>
                   </box>
                 )}
               </Match>
               <Match when={move.pendingNew()}>
                 <box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
-                  <text fg={themeV2.hue.accent(500)} wrapMode="none" truncate>
+                  <text fg={themeV2.hue.accent[500]} wrapMode="none" truncate>
                     (new working copy)
                   </text>
                 </box>
@@ -1584,7 +1584,7 @@ export function Prompt(props: PromptProps) {
               <Match when={true}>
                 <Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
                   {(location) => (
-                    <text fg={themeV2.text.subdued()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
+                    <text fg={themeV2.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
                       {location()}
                     </text>
                   )}
@@ -1598,7 +1598,7 @@ export function Prompt(props: PromptProps) {
                 wrapMode="none"
                 truncate
                 flexShrink={1}
-                fg={editorContextLabelState() === "pending" ? themeV2.hue.accent(500) : themeV2.text.subdued()}
+                fg={editorContextLabelState() === "pending" ? themeV2.hue.accent[500] : themeV2.text.subdued}
               >
                 {file()}
               </text>
@@ -1608,40 +1608,40 @@ export function Prompt(props: PromptProps) {
             <Match when={store.mode === "normal"}>
               <Switch>
                 <Match when={liveWorkStatusVisible() || statusItems().length > 0}>
-                  <text fg={themeV2.text.subdued()} wrapMode="none" truncate flexShrink={1}>
+                  <text fg={themeV2.text.subdued} wrapMode="none" truncate flexShrink={1}>
                     <Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
-                      {(shortcut) => <span style={{ fg: themeV2.text() }}>{shortcut()} </span>}
+                      {(shortcut) => <span style={{ fg: themeV2.text.default }}>{shortcut()} </span>}
                     </Show>
                     <Show when={subagentStatusLabel()}>
-                      {(label) => <span style={{ fg: themeV2.text.subdued() }}>{label()}</span>}
+                      {(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
                     </Show>
                     <Show when={subagentStatusLabel() && shellStatusLabel()}>
-                      <span style={{ fg: themeV2.text.subdued() }}> · </span>
+                      <span style={{ fg: themeV2.text.subdued }}> · </span>
                     </Show>
                     <Show when={shellStatusLabel()}>
-                      {(label) => <span style={{ fg: themeV2.text.subdued() }}>{label()}</span>}
+                      {(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
                     </Show>
                     <Show when={liveWorkStatusVisible() && statusItems().length > 0}>
-                      <span style={{ fg: themeV2.text.subdued() }}> · </span>
+                      <span style={{ fg: themeV2.text.subdued }}> · </span>
                     </Show>
                     <Show when={statusItems().length > 0}>
-                      <span style={{ fg: themeV2.text.subdued() }}>{statusItems().join(" · ")}</span>
+                      <span style={{ fg: themeV2.text.subdued }}>{statusItems().join(" · ")}</span>
                     </Show>
                   </text>
                 </Match>
                 <Match when={true}>
-                  <text fg={themeV2.text()} flexShrink={0}>
-                    {agentShortcut()} <span style={{ fg: themeV2.text.subdued() }}>agents</span>
+                  <text fg={themeV2.text.default} flexShrink={0}>
+                    {agentShortcut()} <span style={{ fg: themeV2.text.subdued }}>agents</span>
                   </text>
                 </Match>
               </Switch>
-              <text fg={themeV2.text()} flexShrink={0}>
-                {paletteShortcut()} <span style={{ fg: themeV2.text.subdued() }}>commands</span>
+              <text fg={themeV2.text.default} flexShrink={0}>
+                {paletteShortcut()} <span style={{ fg: themeV2.text.subdued }}>commands</span>
               </text>
             </Match>
             <Match when={store.mode === "shell"}>
-              <text fg={themeV2.text()} flexShrink={0}>
-                esc <span style={{ fg: themeV2.text.subdued() }}>exit shell mode</span>
+              <text fg={themeV2.text.default} flexShrink={0}>
+                esc <span style={{ fg: themeV2.text.subdued }}>exit shell mode</span>
               </text>
             </Match>
           </Switch>

+ 2 - 2
packages/tui/src/component/reconnecting.tsx

@@ -12,12 +12,12 @@ export function Reconnecting() {
       right={0}
       bottom={0}
       left={0}
-      backgroundColor={themeV2.background()}
+      backgroundColor={themeV2.background.default}
       alignItems="center"
       justifyContent="center"
     >
       <box width={62} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
-        <Spinner color={themeV2.text.subdued()}>Waiting for background service...</Spinner>
+        <Spinner color={themeV2.text.subdued}>Waiting for background service...</Spinner>
       </box>
     </box>
   )

+ 1 - 1
packages/tui/src/component/spinner.tsx

@@ -13,7 +13,7 @@ registerOpencodeSpinner()
 export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
   const { themeV2 } = useTheme()
   const config = useConfig().data
-  const color = () => props.color ?? themeV2.text.subdued()
+  const color = () => props.color ?? themeV2.text.subdued
   return (
     <Show
       when={config.animations ?? true}

+ 2 - 2
packages/tui/src/component/startup-loading.tsx

@@ -54,8 +54,8 @@ export function StartupLoading(props: { ready: () => boolean }) {
   return (
     <Show when={show()}>
       <box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
-        <box backgroundColor={themeV2.background()} paddingLeft={1} paddingRight={1}>
-          <Spinner color={themeV2.text.subdued()}>{text()}</Spinner>
+        <box backgroundColor={themeV2.background.default} paddingLeft={1} paddingRight={1}>
+          <Spinner color={themeV2.text.subdued}>{text()}</Spinner>
         </box>
       </box>
     </Show>

+ 4 - 1
packages/tui/src/context/local.tsx

@@ -91,7 +91,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       })
       const colors = createMemo(() => {
         const step = mode() === "light" ? 800 : 200
-        return dedupeWith(themeV2.categorical(step), (first, second) => first.equals(second))
+        return dedupeWith(
+          themeV2.categorical.map((scale) => scale[step]),
+          (first, second) => first.equals(second),
+        )
       })
       return {
         list() {

+ 6 - 8
packages/tui/src/feature-plugins/home/footer.tsx

@@ -17,7 +17,7 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
 
   return (
     <Show when={directory()}>
-      {(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={themeV2.text.subdued()} />}
+      {(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={themeV2.text.subdued} />}
     </Show>
   )
 }
@@ -31,20 +31,18 @@ function Mcp(props: { context: Plugin.Context }) {
   return (
     <Show when={list().length}>
       <box gap={1} flexDirection="row" flexShrink={0}>
-        <text fg={themeV2.text()}>
+        <text fg={themeV2.text.default}>
           <Switch>
             <Match when={failed()}>
-              <span style={{ fg: themeV2.text.feedback.error() }}>⊙ </span>
+              <span style={{ fg: themeV2.text.feedback.error.default }}>⊙ </span>
             </Match>
             <Match when={true}>
-              <span style={{ fg: count() > 0 ? themeV2.text.feedback.success() : themeV2.text.subdued() }}>
-                ⊙{" "}
-              </span>
+              <span style={{ fg: count() > 0 ? themeV2.text.feedback.success.default : themeV2.text.subdued }}>⊙ </span>
             </Match>
           </Switch>
           {count()} MCP
         </text>
-        <text fg={themeV2.text.subdued()}>/status</text>
+        <text fg={themeV2.text.subdued}>/status</text>
       </box>
     </Show>
   )
@@ -78,7 +76,7 @@ function View(props: { context: Plugin.Context }) {
       <Mcp context={props.context} />
       <box flexGrow={1} />
       <box flexShrink={0}>
-        <text fg={themeV2.text.subdued()}>{InstallationVersion}</text>
+        <text fg={themeV2.text.subdued}>{InstallationVersion}</text>
       </box>
     </box>
   )

+ 5 - 5
packages/tui/src/feature-plugins/sidebar/context.tsx

@@ -20,20 +20,20 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
 
   return (
     <box>
-      <text fg={themeV2.text()}>
+      <text fg={themeV2.text.default}>
         <b>Context</b>
       </text>
-      <Show when={state()} fallback={<text fg={themeV2.text.subdued()}>Not measured</text>}>
+      <Show when={state()} fallback={<text fg={themeV2.text.subdued}>Not measured</text>}>
         {(value) => (
           <>
-            <text fg={themeV2.text.subdued()}>{value().tokens.toLocaleString()} tokens</text>
+            <text fg={themeV2.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
             <Show when={value().percent !== undefined}>
-              <text fg={themeV2.text.subdued()}>{value().percent}% used</text>
+              <text fg={themeV2.text.subdued}>{value().percent}% used</text>
             </Show>
           </>
         )}
       </Show>
-      <text fg={themeV2.text.subdued()}>{money.format(cost())} spent</text>
+      <text fg={themeV2.text.subdued}>{money.format(cost())} spent</text>
     </box>
   )
 }

+ 1 - 1
packages/tui/src/feature-plugins/sidebar/footer.tsx

@@ -12,7 +12,7 @@ function View(props: { context: Plugin.Context }) {
     props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
   )
   return (
-    <Show when={directory()}>{(value) => <FilePath value={value()} maxWidth={38} fg={themeV2.text.subdued()} />}</Show>
+    <Show when={directory()}>{(value) => <FilePath value={value()} maxWidth={38} fg={themeV2.text.subdued} />}</Show>
   )
 }
 

+ 2 - 2
packages/tui/src/feature-plugins/sidebar/lsp.tsx

@@ -5,10 +5,10 @@ function View() {
   const { themeV2 } = useTheme()
   return (
     <box>
-      <text fg={themeV2.text()}>
+      <text fg={themeV2.text.default}>
         <b>LSP</b>
       </text>
-      <text fg={themeV2.text.subdued()}>LSP status unavailable</text>
+      <text fg={themeV2.text.subdued}>LSP status unavailable</text>
     </box>
   )
 }

+ 11 - 11
packages/tui/src/feature-plugins/sidebar/mcp.tsx

@@ -19,12 +19,12 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
   )
 
   const dot = (status: string) => {
-    if (status === "connected") return themeV2.text.feedback.success()
-    if (status === "failed") return themeV2.text.feedback.error()
-    if (status === "disabled") return themeV2.text.subdued()
-    if (status === "needs_auth") return themeV2.text.feedback.warning()
-    if (status === "needs_client_registration") return themeV2.text.feedback.error()
-    return themeV2.text.subdued()
+    if (status === "connected") return themeV2.text.feedback.success.default
+    if (status === "failed") return themeV2.text.feedback.error.default
+    if (status === "disabled") return themeV2.text.subdued
+    if (status === "needs_auth") return themeV2.text.feedback.warning.default
+    if (status === "needs_client_registration") return themeV2.text.feedback.error.default
+    return themeV2.text.subdued
   }
 
   return (
@@ -32,12 +32,12 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
       <box>
         <box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
           <Show when={list().length > 2}>
-            <text fg={themeV2.text()}>{open() ? "▼" : "▶"}</text>
+            <text fg={themeV2.text.default}>{open() ? "▼" : "▶"}</text>
           </Show>
-          <text fg={themeV2.text()}>
+          <text fg={themeV2.text.default}>
             <b>MCP</b>
             <Show when={!open()}>
-              <span style={{ fg: themeV2.text.subdued() }}>
+              <span style={{ fg: themeV2.text.subdued }}>
                 {" "}
                 ({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
               </span>
@@ -56,9 +56,9 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
                 >
                 </text>
-                <text fg={themeV2.text()} wrapMode="word">
+                <text fg={themeV2.text.default} wrapMode="word">
                   {item.name}{" "}
-                  <span style={{ fg: themeV2.text.subdued() }}>
+                  <span style={{ fg: themeV2.text.subdued }}>
                     <Switch fallback={item.status.status}>
                       <Match when={item.status.status === "connected"}>Connected</Match>
                       <Match when={item.status.status === "failed"}>

+ 2 - 2
packages/tui/src/feature-plugins/system/diff-viewer-ui.tsx

@@ -38,7 +38,7 @@ export function Panel(props: Omit<JSX.IntrinsicElements["box"], "border"> & { bo
       ? {}
       : {
           border: panelBorderSides(group?.axis ?? "y", border),
-          borderColor: themeV2.border(),
+          borderColor: themeV2.border.default,
         }
 
   return (
@@ -60,7 +60,7 @@ function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): Bor
 export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
   const group = usePanelGroup()
   const { themeV2 } = useTheme()
-  const color = () => props.color ?? themeV2.border()
+  const color = () => props.color ?? themeV2.border.default
   const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
   if (axis() === "y") {
     return (

+ 4 - 4
packages/tui/src/feature-plugins/system/scrap.tsx

@@ -43,19 +43,19 @@ function Scrap(props: { context: Plugin.Context }) {
   }))
 
   return (
-    <box width={dimensions().width} height={dimensions().height} backgroundColor={themeV2.background()}>
+    <box width={dimensions().width} height={dimensions().height} backgroundColor={themeV2.background.default}>
       <box flexGrow={1} />
       <box
         height={1}
         flexShrink={0}
-        backgroundColor={elevatedTheme.background()}
+        backgroundColor={elevatedTheme.background.default}
         paddingLeft={1}
         paddingRight={1}
         flexDirection="row"
       >
-        <text fg={elevatedTheme.text.subdued()}>~/code/anomalyco/opencode</text>
+        <text fg={elevatedTheme.text.subdued}>~/code/anomalyco/opencode</text>
         <box flexGrow={1} />
-        <text fg={elevatedTheme.text.subdued()}>esc home</text>
+        <text fg={elevatedTheme.text.subdued}>esc home</text>
       </box>
     </box>
   )

+ 9 - 9
packages/tui/src/routes/session/composer/index.tsx

@@ -113,8 +113,8 @@ export function Composer(props: ComposerProps) {
         <box
           {...SplitBorder}
           border={["left"]}
-          borderColor={themeV2.border()}
-          backgroundColor={themeV2.background()}
+          borderColor={themeV2.border.default}
+          backgroundColor={themeV2.background.default}
           paddingLeft={1}
           paddingRight={2}
           paddingTop={1}
@@ -125,7 +125,7 @@ export function Composer(props: ComposerProps) {
               <Show
                 when={tabList().length > 1}
                 fallback={
-                  <text fg={themeV2.text()} attributes={TextAttributes.BOLD}>
+                  <text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
                     {tabList()[0]?.label ?? ""}
                   </text>
                 }
@@ -136,7 +136,7 @@ export function Composer(props: ComposerProps) {
                       const isActive = createMemo(() => store.active === t.id)
                       return (
                         <text
-                          fg={isActive() ? themeV2.text() : themeV2.text.subdued()}
+                          fg={isActive() ? themeV2.text.default : themeV2.text.subdued}
                           attributes={isActive() ? TextAttributes.BOLD : undefined}
                         >
                           {t.label}
@@ -146,7 +146,7 @@ export function Composer(props: ComposerProps) {
                   </For>
                 </box>
               </Show>
-              <text fg={themeV2.text.subdued()} onMouseUp={close}>
+              <text fg={themeV2.text.subdued} onMouseUp={close}>
                 esc
               </text>
             </box>
@@ -156,19 +156,19 @@ export function Composer(props: ComposerProps) {
               <For each={footerHints()}>
                 {(hint) => (
                   <text>
-                    <span style={{ fg: themeV2.text() }}>
+                    <span style={{ fg: themeV2.text.default }}>
                       <b>{hint.label}</b>{" "}
                     </span>
-                    <span style={{ fg: themeV2.text.subdued() }}>{hint.shortcut}</span>
+                    <span style={{ fg: themeV2.text.subdued }}>{hint.shortcut}</span>
                   </text>
                 )}
               </For>
               <Show when={tabList().length > 1}>
                 <text>
-                  <span style={{ fg: themeV2.text() }}>
+                  <span style={{ fg: themeV2.text.default }}>
                     <b>tabs</b>{" "}
                   </span>
-                  <span style={{ fg: themeV2.text.subdued() }}>←/→</span>
+                  <span style={{ fg: themeV2.text.subdued }}>←/→</span>
                 </text>
               </Show>
             </box>

+ 5 - 3
packages/tui/src/routes/session/composer/shell-tab.tsx

@@ -98,7 +98,7 @@ export function ShellTab(props: { sessionID: string }) {
   return (
     <Show when={composer.active("shell")}>
       <scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
-        <Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No shell commands</text>}>
+        <Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued}> No shell commands</text>}>
           <For each={entries()}>
             {(shell, index) => {
               const active = createMemo(() => index() === store.selected)
@@ -107,11 +107,13 @@ export function ShellTab(props: { sessionID: string }) {
                   flexDirection="row"
                   paddingLeft={1}
                   paddingRight={1}
-                  backgroundColor={themeV2.background.action({ focused: active() })}
+                  backgroundColor={
+                    active() ? themeV2.background.action.primary.focused : themeV2.background.action.primary.default
+                  }
                   onMouseOver={() => setStore("selected", index())}
                 >
                   <text
-                    fg={themeV2.text.action({ focused: active() })}
+                    fg={active() ? themeV2.text.action.primary.focused : themeV2.text.action.primary.default}
                     attributes={active() ? TextAttributes.BOLD : undefined}
                     wrapMode="none"
                   >

+ 16 - 11
packages/tui/src/routes/session/composer/subagents-tab.tsx

@@ -205,7 +205,7 @@ export function SubagentsTab(props: { sessionID: string }) {
   return (
     <Show when={composer.active("subagents")}>
       <scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
-        <Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No subagents</text>}>
+        <Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued}> No subagents</text>}>
           <For each={entries()}>
             {(entry, index) => {
               const active = createMemo(() => index() === selected())
@@ -218,7 +218,13 @@ export function SubagentsTab(props: { sessionID: string }) {
                   flexDirection="row"
                   paddingLeft={1}
                   paddingRight={1}
-                  backgroundColor={themeV2.background.action({ focused: active(), selected: entry.current })}
+                  backgroundColor={
+                    active()
+                      ? themeV2.background.action.primary.focused
+                      : entry.current
+                        ? themeV2.background.action.primary.selected
+                        : themeV2.background.action.primary.default
+                  }
                   onMouseOver={() => setStore("selected", index())}
                   onMouseUp={() => {
                     setStore("selected", index())
@@ -227,7 +233,13 @@ export function SubagentsTab(props: { sessionID: string }) {
                 >
                   <box flexGrow={1} minWidth={0} flexDirection="row">
                     <text
-                      fg={themeV2.text.action({ focused: active(), selected: entry.current })}
+                      fg={
+                        active()
+                          ? themeV2.text.action.primary.focused
+                          : entry.current
+                            ? themeV2.text.action.primary.selected
+                            : themeV2.text.action.primary.default
+                      }
                       attributes={active() ? TextAttributes.BOLD : undefined}
                       wrapMode="none"
                     >
@@ -235,14 +247,7 @@ export function SubagentsTab(props: { sessionID: string }) {
                     </text>
                   </box>
                   <Show when={status()}>
-                    <text
-                      fg={
-                        active()
-                          ? themeV2.text.action({ focused: active(), selected: entry.current })
-                          : themeV2.text.subdued()
-                      }
-                      wrapMode="none"
-                    >
+                    <text fg={active() ? themeV2.text.action.primary.focused : themeV2.text.subdued} wrapMode="none">
                       {status()}
                     </text>
                   </Show>

+ 10 - 12
packages/tui/src/routes/session/footer.tsx

@@ -13,9 +13,7 @@ export function Footer() {
   const mcp = createMemo(
     () => (data.location.mcp.server.list() ?? []).filter((x) => x.status.status === "connected").length,
   )
-  const mcpError = createMemo(() =>
-    (data.location.mcp.server.list() ?? []).some((x) => x.status.status === "failed"),
-  )
+  const mcpError = createMemo(() => (data.location.mcp.server.list() ?? []).some((x) => x.status.status === "failed"))
   const permissions = createMemo(() => {
     if (route.data.type !== "session") return []
     return data.session.permission.list(route.data.sessionID) ?? []
@@ -54,35 +52,35 @@ export function Footer() {
 
   return (
     <box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
-      <text fg={themeV2.text.subdued()}>{directory()}</text>
+      <text fg={themeV2.text.subdued}>{directory()}</text>
       <box gap={2} flexDirection="row" flexShrink={0}>
         <Switch>
           <Match when={store.welcome}>
-            <text fg={themeV2.text()}>
-              Get started <span style={{ fg: themeV2.text.subdued() }}>/connect</span>
+            <text fg={themeV2.text.default}>
+              Get started <span style={{ fg: themeV2.text.subdued }}>/connect</span>
             </text>
           </Match>
           <Match when={connected()}>
             <Show when={permissions().length > 0}>
-              <text fg={themeV2.text.feedback.warning()}>
-                <span style={{ fg: themeV2.text.feedback.warning() }}>△</span> {permissions().length} Permission
+              <text fg={themeV2.text.feedback.warning.default}>
+                <span style={{ fg: themeV2.text.feedback.warning.default }}>△</span> {permissions().length} Permission
                 {permissions().length > 1 ? "s" : ""}
               </text>
             </Show>
             <Show when={mcp()}>
-              <text fg={themeV2.text()}>
+              <text fg={themeV2.text.default}>
                 <Switch>
                   <Match when={mcpError()}>
-                    <span style={{ fg: themeV2.text.feedback.error() }}>⊙ </span>
+                    <span style={{ fg: themeV2.text.feedback.error.default }}>⊙ </span>
                   </Match>
                   <Match when={true}>
-                    <span style={{ fg: themeV2.text.feedback.success() }}>⊙ </span>
+                    <span style={{ fg: themeV2.text.feedback.success.default }}>⊙ </span>
                   </Match>
                 </Switch>
                 {mcp()} MCP
               </text>
             </Show>
-            <text fg={themeV2.text.subdued()}>/status</text>
+            <text fg={themeV2.text.subdued}>/status</text>
           </Match>
         </Switch>
       </box>

+ 84 - 64
packages/tui/src/routes/session/form.tsx

@@ -624,27 +624,27 @@ export function FormPrompt(props: { form: FormWithLocation }) {
 
   return (
     <box
-      backgroundColor={themeV2.background()}
+      backgroundColor={themeV2.background.default}
       border={["left"]}
-      borderColor={themeV2.hue.interactive(themeMode() === "light" ? 800 : 200)}
+      borderColor={themeV2.hue.interactive[themeMode() === "light" ? 800 : 200]}
       customBorderChars={SplitBorder.customBorderChars}
     >
       <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
         <box paddingLeft={1}>
-          <text fg={themeV2.text.subdued()}>{props.form.title}</text>
+          <text fg={themeV2.text.subdued}>{props.form.title}</text>
         </box>
         <Show when={message()}>
           <box paddingLeft={1}>
-            <text fg={themeV2.text()}>{message()}</text>
+            <text fg={themeV2.text.default}>{message()}</text>
           </box>
         </Show>
         <Show when={!single() && !tabbed()}>
           <box flexDirection="row" gap={1} paddingLeft={1}>
-            <text fg={themeV2.text.subdued()}>
+            <text fg={themeV2.text.subdued}>
               {confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
             </text>
             <Show when={fields().length > 0}>
-              <text fg={themeV2.text.subdued()}>
+              <text fg={themeV2.text.subdued}>
                 · {answered()}/{fields().length} completed
               </text>
             </Show>
@@ -661,10 +661,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
                     paddingRight={2}
                     backgroundColor={
                       isTab()
-                        ? themeV2.background.formfield("selected")
+                        ? themeV2.background.formfield.selected
                         : tabHover() === index()
-                          ? themeV2.background.formfield("focused")
-                          : themeV2.background()
+                          ? themeV2.background.formfield.focused
+                          : themeV2.background.default
                     }
                     onMouseOver={() => setTabHover(index())}
                     onMouseOut={() => setTabHover(null)}
@@ -676,12 +676,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
                     <text
                       fg={
                         isTab()
-                          ? themeV2.text.formfield("selected")
+                          ? themeV2.text.formfield.selected
                           : tabHover() === index()
-                            ? themeV2.text.formfield("focused")
+                            ? themeV2.text.formfield.focused
                             : isAnswered()
-                              ? themeV2.text()
-                              : themeV2.text.subdued()
+                              ? themeV2.text.default
+                              : themeV2.text.subdued
                       }
                     >
                       {truncate(formLabel(item), 24)}
@@ -693,10 +693,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
             <box
               backgroundColor={
                 confirm()
-                  ? themeV2.background.formfield("selected")
+                  ? themeV2.background.formfield.selected
                   : tabHover() === "confirm"
-                    ? themeV2.background.formfield("focused")
-                    : themeV2.background()
+                    ? themeV2.background.formfield.focused
+                    : themeV2.background.default
               }
               onMouseOver={() => setTabHover("confirm")}
               onMouseOut={() => setTabHover(null)}
@@ -705,7 +705,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
                 selectTabFromMouse()
               }}
             >
-              <text fg={themeV2.text.formfield(confirm() ? "selected" : "default")}>Confirm</text>
+              <text fg={confirm() ? themeV2.text.formfield.selected : themeV2.text.formfield.default}>Confirm</text>
             </box>
           </box>
         </Show>
@@ -714,13 +714,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
           {(external) => (
             <box paddingLeft={1} gap={1}>
               <Show when={external().title}>
-                <text fg={themeV2.text()}>{external().title}</text>
+                <text fg={themeV2.text.default}>{external().title}</text>
               </Show>
               <Show when={external().description}>
-                <text fg={themeV2.text.subdued()}>{external().description}</text>
+                <text fg={themeV2.text.subdued}>{external().description}</text>
               </Show>
               <text
-                fg={themeV2.background.action()}
+                fg={themeV2.background.action.primary.default}
                 onMouseUp={() => {
                   if (renderer.getSelection()?.getSelectedText()) return
                   openExternal()
@@ -729,7 +729,9 @@ export function FormPrompt(props: { form: FormWithLocation }) {
                 {external().url}
               </text>
               <text
-                fg={store.answers[external().key] === true ? themeV2.text.feedback.success() : themeV2.text.subdued()}
+                fg={
+                  store.answers[external().key] === true ? themeV2.text.feedback.success.default : themeV2.text.subdued
+                }
               >
                 {store.answers[external().key] === true
                   ? "✓ Acknowledged"
@@ -744,7 +746,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
         <Show when={!confirm() && answerField()}>
           <box paddingLeft={1} gap={1}>
             <box>
-              <text fg={themeV2.text()}>
+              <text fg={themeV2.text.default}>
                 {answerField()!.description ?? formLabel(answerField()!)}
                 {answerField()!.required ? " (required)" : ""}
                 {multi() ? " (select all that apply)" : ""}
@@ -765,12 +767,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
                     input() || formDisplayValue(answerField()!, store.answers[answerField()!.key], "(none)")
                   }
                   placeholder={placeholder()}
-                  placeholderColor={themeV2.text.subdued()}
+                  placeholderColor={themeV2.text.subdued}
                   minHeight={1}
                   maxHeight={6}
-                  textColor={themeV2.text()}
-                  focusedTextColor={themeV2.text()}
-                  cursorColor={themeV2.text()}
+                  textColor={themeV2.text.default}
+                  focusedTextColor={themeV2.text.default}
+                  cursorColor={themeV2.text.default}
                 />
               </box>
             </Show>
@@ -795,25 +797,39 @@ export function FormPrompt(props: { form: FormWithLocation }) {
                       >
                         <box flexDirection="row">
                           <box
-                            backgroundColor={active() ? themeV2.background.formfield("focused") : themeV2.background()}
+                            backgroundColor={
+                              active() ? themeV2.background.formfield.focused : themeV2.background.default
+                            }
                             paddingRight={1}
                           >
-                            <text fg={themeV2.text.formfield(active() ? "focused" : "default")}>{`${i() + 1}.`}</text>
+                            <text
+                              fg={active() ? themeV2.text.formfield.focused : themeV2.text.formfield.default}
+                            >{`${i() + 1}.`}</text>
                           </box>
                           <box
-                            backgroundColor={active() ? themeV2.background.formfield("focused") : themeV2.background()}
+                            backgroundColor={
+                              active() ? themeV2.background.formfield.focused : themeV2.background.default
+                            }
                           >
-                            <text fg={themeV2.text.formfield(active() ? "focused" : picked() ? "selected" : "default")}>
+                            <text
+                              fg={
+                                active()
+                                  ? themeV2.text.formfield.focused
+                                  : picked()
+                                    ? themeV2.text.formfield.selected
+                                    : themeV2.text.formfield.default
+                              }
+                            >
                               {multi() ? `[${picked() ? "✓" : " "}] ${row.label}` : row.label}
                             </text>
                           </box>
                           <Show when={!multi()}>
-                            <text fg={themeV2.text.formfield("selected")}>{picked() ? " ✓" : ""}</text>
+                            <text fg={themeV2.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
                           </Show>
                         </box>
                         <Show when={row.description}>
                           <box paddingLeft={3}>
-                            <text fg={themeV2.text.subdued()}>{row.description}</text>
+                            <text fg={themeV2.text.subdued}>{row.description}</text>
                           </box>
                         </Show>
                       </box>
@@ -831,28 +847,30 @@ export function FormPrompt(props: { form: FormWithLocation }) {
                   >
                     <box flexDirection="row">
                       <box
-                        backgroundColor={other() ? themeV2.background.formfield("focused") : themeV2.background()}
+                        backgroundColor={other() ? themeV2.background.formfield.focused : themeV2.background.default}
                         paddingRight={1}
                       >
-                        <text fg={themeV2.text.formfield(other() ? "focused" : "default")}>
+                        <text fg={other() ? themeV2.text.formfield.focused : themeV2.text.formfield.default}>
                           {`${rows().length + 1}.`}
                         </text>
                       </box>
-                      <box backgroundColor={other() ? themeV2.background.formfield("focused") : themeV2.background()}>
+                      <box
+                        backgroundColor={other() ? themeV2.background.formfield.focused : themeV2.background.default}
+                      >
                         <text
                           fg={
                             other()
-                              ? themeV2.text.formfield("focused")
+                              ? themeV2.text.formfield.focused
                               : customPicked()
-                                ? themeV2.text.feedback.success()
-                                : themeV2.text()
+                                ? themeV2.text.feedback.success.default
+                                : themeV2.text.default
                           }
                         >
                           {multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
                         </text>
                       </box>
                       <Show when={!multi()}>
-                        <text fg={themeV2.text.feedback.success()}>{customPicked() ? " ✓" : ""}</text>
+                        <text fg={themeV2.text.feedback.success.default}>{customPicked() ? " ✓" : ""}</text>
                       </Show>
                     </box>
                     <Show when={store.editing}>
@@ -868,18 +886,18 @@ export function FormPrompt(props: { form: FormWithLocation }) {
                           }}
                           initialValue={input()}
                           placeholder="Type your own answer"
-                          placeholderColor={themeV2.text.subdued()}
+                          placeholderColor={themeV2.text.subdued}
                           minHeight={1}
                           maxHeight={6}
-                          textColor={themeV2.text()}
-                          focusedTextColor={themeV2.text()}
-                          cursorColor={themeV2.text()}
+                          textColor={themeV2.text.default}
+                          focusedTextColor={themeV2.text.default}
+                          cursorColor={themeV2.text.default}
                         />
                       </box>
                     </Show>
                     <Show when={!store.editing && input()}>
                       <box paddingLeft={3}>
-                        <text fg={themeV2.text.subdued()}>{input()}</text>
+                        <text fg={themeV2.text.subdued}>{input()}</text>
                       </box>
                     </Show>
                   </box>
@@ -892,7 +910,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
         <Show when={confirm()}>
           <Show when={tabbed()}>
             <box paddingLeft={1}>
-              <text fg={themeV2.text()}>Review</text>
+              <text fg={themeV2.text.default}>Review</text>
             </box>
           </Show>
           <scrollbox
@@ -907,10 +925,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
                   return (
                     <box paddingLeft={1}>
                       <text>
-                        <span style={{ fg: themeV2.text.subdued() }}>{truncate(formLabel(item), 40)}:</span>{" "}
+                        <span style={{ fg: themeV2.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
                         <span
                           style={{
-                            fg: acknowledged() ? themeV2.text.feedback.success() : themeV2.text.feedback.error(),
+                            fg: acknowledged()
+                              ? themeV2.text.feedback.success.default
+                              : themeV2.text.feedback.error.default,
                           }}
                         >
                           {acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
@@ -926,15 +946,15 @@ export function FormPrompt(props: { form: FormWithLocation }) {
                 return (
                   <box paddingLeft={1}>
                     <text>
-                      <span style={{ fg: themeV2.text.subdued() }}>{truncate(formLabel(item), 40)}:</span>{" "}
+                      <span style={{ fg: themeV2.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
                       <span
                         style={{
                           fg:
                             invalid() || missing()
-                              ? themeV2.text.feedback.error()
+                              ? themeV2.text.feedback.error.default
                               : answered()
-                                ? themeV2.text()
-                                : themeV2.text.subdued(),
+                                ? themeV2.text.default
+                                : themeV2.text.subdued,
                         }}
                       >
                         {invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
@@ -958,41 +978,41 @@ export function FormPrompt(props: { form: FormWithLocation }) {
       >
         <box flexDirection="row" gap={2}>
           <Show when={!single()}>
-            <text fg={themeV2.text()}>
-              {"⇆"} <span style={{ fg: themeV2.text.subdued() }}>tab</span>
+            <text fg={themeV2.text.default}>
+              {"⇆"} <span style={{ fg: themeV2.text.subdued }}>tab</span>
             </text>
           </Show>
           <Show when={!confirm() && !textual() && !externalField()}>
-            <text fg={themeV2.text()}>
-              {"↑↓"} <span style={{ fg: themeV2.text.subdued() }}>select</span>
+            <text fg={themeV2.text.default}>
+              {"↑↓"} <span style={{ fg: themeV2.text.subdued }}>select</span>
             </text>
           </Show>
           <Show when={confirm() && fields().length > 0}>
-            <text fg={themeV2.text()}>
-              {"↑↓"} <span style={{ fg: themeV2.text.subdued() }}>scroll</span>
+            <text fg={themeV2.text.default}>
+              {"↑↓"} <span style={{ fg: themeV2.text.subdued }}>scroll</span>
             </text>
           </Show>
           <text
-            fg={themeV2.text()}
+            fg={themeV2.text.default}
             onMouseUp={() => {
               if (renderer.getSelection()?.getSelectedText()) return
               if (confirm()) submit()
               if (externalField()) acknowledgeExternal()
             }}
           >
-            enter <span style={{ fg: themeV2.text.subdued() }}>{actionLabel()}</span>
+            enter <span style={{ fg: themeV2.text.subdued }}>{actionLabel()}</span>
           </text>
           <Show when={externalField() && clipboard.write}>
-            <text fg={themeV2.text()} onMouseUp={copyExternal}>
-              c <span style={{ fg: themeV2.text.subdued() }}>copy</span>
+            <text fg={themeV2.text.default} onMouseUp={copyExternal}>
+              c <span style={{ fg: themeV2.text.subdued }}>copy</span>
             </text>
           </Show>
-          <text fg={themeV2.text()} onMouseUp={cancel}>
-            esc <span style={{ fg: themeV2.text.subdued() }}>dismiss</span>
+          <text fg={themeV2.text.default} onMouseUp={cancel}>
+            esc <span style={{ fg: themeV2.text.subdued }}>dismiss</span>
           </text>
         </box>
         <Show when={store.error}>
-          <text fg={themeV2.text.feedback.error()}>{store.error}</text>
+          <text fg={themeV2.text.feedback.error.default}>{store.error}</text>
         </Show>
       </box>
     </box>

+ 131 - 127
packages/tui/src/routes/session/index.tsx

@@ -919,8 +919,8 @@ export function Session() {
                 paddingLeft: 1,
                 visible: showScrollbar(),
                 trackOptions: {
-                  backgroundColor: themeV2.raise(themeV2.background.surface.offset()),
-                  foregroundColor: themeV2.border(),
+                  backgroundColor: themeV2.raise(themeV2.background.surface.offset),
+                  foregroundColor: themeV2.border.default,
                 },
               }}
               stickyScroll={!navigationMessage()}
@@ -1098,8 +1098,8 @@ function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
     <Show when={visible() && shortcut()}>
       {(value) => (
         <box marginTop={1} paddingLeft={3} flexShrink={0}>
-          <text fg={themeV2.text.subdued()}>
-            Press <span style={{ fg: themeV2.text() }}>{value()}</span> to move running work to the background
+          <text fg={themeV2.text.subdued}>
+            Press <span style={{ fg: themeV2.text.default }}>{value()}</span> to move running work to the background
           </text>
         </box>
       )}
@@ -1209,13 +1209,13 @@ function SessionReasoningGroupView(props: {
             icon={expanded() ? "-" : "+"}
             color={
               !props.completed
-                ? themeV2.text()
+                ? themeV2.text.default
                 : hover() || expanded()
-                  ? themeV2.text.feedback.warning()
+                  ? themeV2.text.feedback.warning.default
                   : RGBA.fromValues(
-                      themeV2.text.feedback.warning().r,
-                      themeV2.text.feedback.warning().g,
-                      themeV2.text.feedback.warning().b,
+                      themeV2.text.feedback.warning.default.r,
+                      themeV2.text.feedback.warning.default.g,
+                      themeV2.text.feedback.warning.default.b,
                       0.6,
                     )
             }
@@ -1258,7 +1258,7 @@ function SessionReasoningGroupView(props: {
                         <box
                           border={["left"]}
                           customBorderChars={SplitBorder.customBorderChars}
-                          borderColor={themeV2.raise(themeV2.background.surface.offset())}
+                          borderColor={themeV2.raise(themeV2.background.surface.offset)}
                           paddingLeft={1}
                         >
                           <code
@@ -1268,7 +1268,7 @@ function SessionReasoningGroupView(props: {
                             syntaxStyle={syntax()}
                             content={content()}
                             conceal={ctx.markdownMode() === "rendered"}
-                            fg={themeV2.text.subdued()}
+                            fg={themeV2.text.subdued}
                           />
                         </box>
                       </box>
@@ -1326,7 +1326,7 @@ function SessionGroupView(props: {
         <Show when={grouped().length > 0}>
           <InlineToolRow
             icon={props.completed ? "→" : "✱"}
-            color={hover() ? themeV2.text() : themeV2.text.subdued()}
+            color={hover() ? themeV2.text.default : themeV2.text.subdued}
             complete={props.completed}
             pending={label()}
             spinner={!props.completed}
@@ -1372,25 +1372,25 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
           paddingTop={1}
           paddingBottom={1}
           paddingLeft={2}
-          backgroundColor={themeV2.background()}
+          backgroundColor={themeV2.background.default}
           customBorderChars={SplitBorder.customBorderChars}
-          borderColor={themeV2.text.feedback.error()}
+          borderColor={themeV2.text.feedback.error.default}
         >
-          <text fg={themeV2.text.subdued()}>{errorMessage(props.message.error)}</text>
+          <text fg={themeV2.text.subdued}>{errorMessage(props.message.error)}</text>
         </box>
       </Show>
       <AssistantRetry retry={props.message.retry} />
       <box paddingLeft={3} marginTop={props.message.error && !interrupted() ? 1 : 0}>
         <text>
-          <span style={{ fg: props.message.error ? themeV2.text.subdued() : local.agent.color(props.message.agent) }}>
+          <span style={{ fg: props.message.error ? themeV2.text.subdued : local.agent.color(props.message.agent) }}>
             {Locale.titlecase(props.message.agent)}
           </span>
-          <span style={{ fg: themeV2.text.subdued() }}> · {model()}</span>
+          <span style={{ fg: themeV2.text.subdued }}> · {model()}</span>
           <Show when={duration()}>
-            <span style={{ fg: themeV2.text.subdued() }}> · {Locale.duration(duration())}</span>
+            <span style={{ fg: themeV2.text.subdued }}> · {Locale.duration(duration())}</span>
           </Show>
           <Show when={interrupted()}>
-            <span style={{ fg: themeV2.text.subdued() }}> · interrupted</span>
+            <span style={{ fg: themeV2.text.subdued }}> · interrupted</span>
           </Show>
         </text>
       </box>
@@ -1409,7 +1409,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
   }
   return (
     <box paddingLeft={3}>
-      <text fg={themeV2.text.subdued()}>{text()}</text>
+      <text fg={themeV2.text.subdued}>{text()}</text>
     </box>
   )
 }
@@ -1436,15 +1436,15 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
   const heading = () => `${state() === "completed" ? "↳" : "!"} ${actor()} ${status()}`
   const suffix = () => Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - stringWidth(heading())))
   const color = () => {
-    if (state() === "error") return themeV2.text.feedback.error()
-    if (state() === "cancelled") return themeV2.text.feedback.warning()
-    return themeV2.text.feedback.info()
+    if (state() === "error") return themeV2.text.feedback.error.default
+    if (state() === "cancelled") return themeV2.text.feedback.warning.default
+    return themeV2.text.feedback.info.default
   }
   return (
     <Show
       when={completion()}
       fallback={
-        <InlineToolRow icon="◈" color={themeV2.text.subdued()} pending="Notice" complete={true}>
+        <InlineToolRow icon="◈" color={themeV2.text.subdued} pending="Notice" complete={true}>
           {text()}
         </InlineToolRow>
       }
@@ -1452,7 +1452,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
       <box marginLeft={3}>
         <text wrapMode="none">
           <span style={{ fg: color() }}>{heading()}</span>
-          <span style={{ fg: themeV2.text.subdued() }}>{suffix()}</span>
+          <span style={{ fg: themeV2.text.subdued }}>{suffix()}</span>
         </text>
       </box>
     </Show>
@@ -1462,7 +1462,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
 function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { type: "skill" }> }) {
   const { themeV2 } = useTheme()
   return (
-    <InlineToolRow icon="→" color={themeV2.text.subdued()} pending="Skill" complete={true}>
+    <InlineToolRow icon="→" color={themeV2.text.subdued} pending="Skill" complete={true}>
       Skill {props.message.name}
     </InlineToolRow>
   )
@@ -1476,7 +1476,8 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
   const text = () =>
     props.message.status === "failed" ? (cancelled() ? "" : props.message.error.message) : props.message.summary
   const content = createMemo(() => text().trim())
-  const color = () => (status() === "failed" && !cancelled() ? themeV2.text.feedback.error() : themeV2.text.subdued())
+  const color = () =>
+    status() === "failed" && !cancelled() ? themeV2.text.feedback.error.default : themeV2.text.subdued
   return (
     <box>
       <box flexDirection="row" alignItems="center">
@@ -1508,8 +1509,8 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
             content={content()}
             tableOptions={{ style: "grid" }}
             conceal={ctx.markdownMode() === "rendered"}
-            fg={themeV2.markdown()}
-            bg={themeV2.background()}
+            fg={themeV2.markdown.text}
+            bg={themeV2.background.default}
           />
         </box>
       </Show>
@@ -1521,12 +1522,12 @@ function CompactionQueued() {
   const { themeV2 } = useTheme()
   return (
     <box flexDirection="row" alignItems="center">
-      <box border={["top"]} borderColor={themeV2.border()} flexGrow={1} />
+      <box border={["top"]} borderColor={themeV2.border.default} flexGrow={1} />
       <box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
-        <text fg={themeV2.text.subdued()}>◇</text>
-        <text fg={themeV2.text.subdued()}>Compaction queued</text>
+        <text fg={themeV2.text.subdued}>◇</text>
+        <text fg={themeV2.text.subdued}>Compaction queued</text>
       </box>
-      <box border={["top"]} borderColor={themeV2.border()} flexGrow={1} />
+      <box border={["top"]} borderColor={themeV2.border.default} flexGrow={1} />
     </box>
   )
 }
@@ -1572,15 +1573,15 @@ function RevertMessage(props: {
       marginTop={1}
       border={["left"]}
       customBorderChars={SplitBorder.customBorderChars}
-      borderColor={themeV2.background()}
+      borderColor={themeV2.background.default}
     >
       <box
         paddingTop={1}
         paddingBottom={1}
         paddingLeft={2}
-        backgroundColor={hover() ? themeV2.raise(themeV2.background()) : themeV2.background()}
+        backgroundColor={hover() ? themeV2.raise(themeV2.background.default) : themeV2.background.default}
       >
-        <text fg={themeV2.text.subdued()}>
+        <text fg={themeV2.text.subdued}>
           {props.count} message{props.count === 1 ? "" : "s"} reverted
         </text>
         <Show when={props.files.length > 0}>
@@ -1588,7 +1589,7 @@ function RevertMessage(props: {
             <For each={props.files}>
               {(file) => (
                 <box flexDirection="row" gap={1} flexShrink={0}>
-                  <text fg={themeV2.text.subdued()}>{statusLabel(file.status)}</text>
+                  <text fg={themeV2.text.subdued}>{statusLabel(file.status)}</text>
                   <FilePath
                     value={file.file}
                     maxWidth={Math.max(
@@ -1598,21 +1599,21 @@ function RevertMessage(props: {
                         (file.additions > 0 ? stringWidth(`+${file.additions}`) + 1 : 0) -
                         (file.deletions > 0 ? stringWidth(`-${file.deletions}`) + 1 : 0),
                     )}
-                    fg={themeV2.text()}
+                    fg={themeV2.text.default}
                   />
                   <Show when={file.additions > 0}>
-                    <text fg={themeV2.diff.text.added()}>+{file.additions}</text>
+                    <text fg={themeV2.diff.text.added}>+{file.additions}</text>
                   </Show>
                   <Show when={file.deletions > 0}>
-                    <text fg={themeV2.diff.text.removed()}>-{file.deletions}</text>
+                    <text fg={themeV2.diff.text.removed}>-{file.deletions}</text>
                   </Show>
                 </box>
               )}
             </For>
           </box>
         </Show>
-        <text fg={themeV2.text.subdued()}>
-          <span style={{ fg: themeV2.text() }}>{redoKey()}</span> or /redo to restore
+        <text fg={themeV2.text.subdued}>
+          <span style={{ fg: themeV2.text.default }}>{redoKey()}</span> or /redo to restore
         </text>
       </box>
     </box>
@@ -1630,13 +1631,13 @@ function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "she
       paddingBottom={1}
       paddingLeft={2}
       gap={1}
-      backgroundColor={themeV2.background()}
+      backgroundColor={themeV2.background.default}
       customBorderChars={SplitBorder.customBorderChars}
-      borderColor={themeV2.background()}
+      borderColor={themeV2.background.default}
     >
-      <text fg={themeV2.text()}>$ {props.message.command}</text>
+      <text fg={themeV2.text.default}>$ {props.message.command}</text>
       <Show when={output()}>
-        <text fg={themeV2.text.subdued()}>{output()}</text>
+        <text fg={themeV2.text.subdued}>{output()}</text>
       </Show>
     </box>
   )
@@ -1661,7 +1662,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
     <Show when={props.message.text.trim() || files().length}>
       <box
         border={["left"]}
-        borderColor={queued() ? themeV2.border() : color()}
+        borderColor={queued() ? themeV2.border.default : color()}
         customBorderChars={SplitBorder.customBorderChars}
       >
         <box
@@ -1684,27 +1685,27 @@ function UserMessage(props: { message: SessionMessageUser }) {
           paddingTop={1}
           paddingBottom={1}
           paddingLeft={2}
-          backgroundColor={hover() ? themeV2.raise(themeV2.background()) : themeV2.background()}
+          backgroundColor={hover() ? themeV2.raise(themeV2.background.default) : themeV2.background.default}
           flexShrink={0}
         >
-          <text fg={themeV2.text()}>{props.message.text}</text>
+          <text fg={themeV2.text.default}>{props.message.text}</text>
           <Show when={files().length}>
             <box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
               <For each={files()}>
                 {(file) => {
                   const label = file.mime === "application/x-directory" ? "dir" : "file"
                   return (
-                    <text fg={themeV2.text()}>
+                    <text fg={themeV2.text.default}>
                       <span
                         style={{
-                          bg: themeV2.hue.accent(mode() === "light" ? 700 : 200),
-                          fg: themeV2.background(),
+                          bg: themeV2.hue.accent[mode() === "light" ? 700 : 200],
+                          fg: themeV2.background.default,
                           bold: true,
                         }}
                       >
                         {` ${label} `}
                       </span>
-                      <span style={{ bg: themeV2.raise(themeV2.background()), fg: themeV2.text.subdued() }}>
+                      <span style={{ bg: themeV2.raise(themeV2.background.default), fg: themeV2.text.subdued }}>
                         {" "}
                         {file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
                       </span>
@@ -1809,11 +1810,11 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
           paddingTop={1}
           paddingBottom={1}
           paddingLeft={2}
-          backgroundColor={themeV2.background()}
+          backgroundColor={themeV2.background.default}
           customBorderChars={SplitBorder.customBorderChars}
-          borderColor={themeV2.text.feedback.error()}
+          borderColor={themeV2.text.feedback.error.default}
         >
-          <text fg={themeV2.text.subdued()}>{errorMessage(props.message.error)}</text>
+          <text fg={themeV2.text.subdued}>{errorMessage(props.message.error)}</text>
         </box>
       </Show>
       <AssistantRetry retry={props.message.retry} />
@@ -1821,14 +1822,12 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
         <Match when={props.last || final() || props.message.error}>
           <box paddingLeft={3}>
             <text>
-              <span
-                style={{ fg: props.message.error ? themeV2.text.subdued() : local.agent.color(props.message.agent) }}
-              >
+              <span style={{ fg: props.message.error ? themeV2.text.subdued : local.agent.color(props.message.agent) }}>
                 {Locale.titlecase(props.message.agent)}
               </span>
-              <span style={{ fg: themeV2.text.subdued() }}> · {model()}</span>
+              <span style={{ fg: themeV2.text.subdued }}> · {model()}</span>
               <Show when={duration()}>
-                <span style={{ fg: themeV2.text.subdued() }}> · {Locale.duration(duration())}</span>
+                <span style={{ fg: themeV2.text.subdued }}> · {Locale.duration(duration())}</span>
               </Show>
             </text>
           </box>
@@ -1844,7 +1843,7 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
     <Show when={props.retry}>
       {(retry) => (
         <box paddingLeft={3} marginTop={1}>
-          <text fg={themeV2.text.subdued()}>
+          <text fg={themeV2.text.subdued}>
             Retry attempt {retry().attempt} scheduled: {retry().error.message} [{retry().error.type}]
           </text>
         </box>
@@ -1867,7 +1866,7 @@ function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; activ
     <box flexDirection="column">
       <InlineToolRow
         icon="✱"
-        color={themeV2.text.subdued()}
+        color={themeV2.text.subdued}
         complete={!props.active}
         pending="Exploring"
         spinner={props.active}
@@ -1877,7 +1876,7 @@ function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; activ
       <For each={props.parts}>
         {(part, index) => (
           <box paddingLeft={5}>
-            <text fg={part.state.status === "error" ? themeV2.text.feedback.error() : themeV2.text.subdued()}>
+            <text fg={part.state.status === "error" ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
               {index() === props.parts.length - 1 ? "└" : "├"} {label(part)}
             </text>
           </box>
@@ -1922,7 +1921,7 @@ function ReasoningPart(props: {
         <box
           border={!inMinimal() || expanded() ? ["left"] : undefined}
           customBorderChars={SplitBorder.customBorderChars}
-          borderColor={themeV2.raise(themeV2.background())}
+          borderColor={themeV2.raise(themeV2.background.default)}
           paddingLeft={!inMinimal() || expanded() ? 1 : 0}
         >
           <box onMouseUp={toggle}>
@@ -1940,7 +1939,7 @@ function ReasoningPart(props: {
             <box
               border={["left"]}
               customBorderChars={SplitBorder.customBorderChars}
-              borderColor={themeV2.raise(themeV2.background())}
+              borderColor={themeV2.raise(themeV2.background.default)}
               paddingLeft={inMinimal() ? 3 : 1}
             >
               <code
@@ -1950,7 +1949,7 @@ function ReasoningPart(props: {
                 syntaxStyle={syntax()}
                 content={content()}
                 conceal={ctx.markdownMode() === "rendered"}
-                fg={themeV2.text.subdued()}
+                fg={themeV2.text.subdued}
               />
             </box>
           </box>
@@ -1976,12 +1975,12 @@ function ReasoningHeader(props: {
   const fg = () =>
     props.open
       ? RGBA.fromValues(
-          themeV2.text.feedback.warning().r,
-          themeV2.text.feedback.warning().g,
-          themeV2.text.feedback.warning().b,
+          themeV2.text.feedback.warning.default.r,
+          themeV2.text.feedback.warning.default.g,
+          themeV2.text.feedback.warning.default.b,
           0.6,
         )
-      : themeV2.text.feedback.warning()
+      : themeV2.text.feedback.warning.default
 
   return (
     <Switch>
@@ -2027,8 +2026,8 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
           content={props.part.text.trim()}
           tableOptions={{ style: "grid" }}
           conceal={ctx.markdownMode() === "rendered"}
-          fg={themeV2.markdown()}
-          bg={themeV2.background()}
+          fg={themeV2.markdown.text}
+          bg={themeV2.background.default}
         />
       </box>
     </Show>
@@ -2135,7 +2134,7 @@ function GenericTool(props: ToolProps) {
           <Show when={Object.keys(props.input).length > 0}>
             <box gap={1}>
               <text>
-                <span style={{ bg: themeV2.raise(themeV2.background()), fg: themeV2.text.subdued() }}> Input </span>
+                <span style={{ bg: themeV2.raise(themeV2.background.default), fg: themeV2.text.subdued }}> Input </span>
               </text>
               <box paddingLeft={1}>
                 <code
@@ -2144,7 +2143,7 @@ function GenericTool(props: ToolProps) {
                   syntaxStyle={syntax()}
                   conceal={false}
                   drawUnstyledText={false}
-                  fg={themeV2.text()}
+                  fg={themeV2.text.default}
                 />
               </box>
             </box>
@@ -2153,10 +2152,13 @@ function GenericTool(props: ToolProps) {
             {(value) => (
               <box gap={1}>
                 <text>
-                  <span style={{ bg: themeV2.raise(themeV2.background()), fg: themeV2.text.subdued() }}> Output </span>
+                  <span style={{ bg: themeV2.raise(themeV2.background.default), fg: themeV2.text.subdued }}>
+                    {" "}
+                    Output{" "}
+                  </span>
                 </text>
                 <box paddingLeft={1}>
-                  <text fg={themeV2.text()} wrapMode="word">
+                  <text fg={themeV2.text.default} wrapMode="word">
                     {value()}
                   </text>
                 </box>
@@ -2208,10 +2210,10 @@ function InlineTool(props: {
   const clickable = createMemo(() => Boolean(props.onClick || failed()))
   const fg = createMemo(() => {
     if (props.color) return props.color
-    if (permission()) return themeV2.text.feedback.warning()
-    if (failed()) return themeV2.text.feedback.error()
-    if (hover() && props.onClick) return themeV2.text()
-    return themeV2.text.subdued()
+    if (permission()) return themeV2.text.feedback.warning.default
+    if (failed()) return themeV2.text.feedback.error.default
+    if (hover() && props.onClick) return themeV2.text.default
+    return themeV2.text.subdued
   })
 
   return (
@@ -2219,7 +2221,7 @@ function InlineTool(props: {
       icon={props.icon}
       iconColor={props.iconColor}
       color={fg()}
-      errorColor={themeV2.text.feedback.error()}
+      errorColor={themeV2.text.feedback.error.default}
       failed={failed()}
       denied={Boolean(denied())}
       error={error()}
@@ -2343,7 +2345,7 @@ function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.El
 function StatusBadge(props: { children: string }) {
   const { themeV2 } = useTheme()
   return (
-    <text flexShrink={0} bg={themeV2.raise(themeV2.background())} fg={themeV2.text.subdued()}>
+    <text flexShrink={0} bg={themeV2.raise(themeV2.background.default)} fg={themeV2.text.subdued}>
       {" "}
       {props.children}{" "}
     </text>
@@ -2376,9 +2378,9 @@ function BlockTool(props: {
       paddingBottom={1}
       paddingLeft={2}
       gap={1}
-      backgroundColor={hover() ? themeV2.raise(themeV2.background()) : themeV2.background()}
+      backgroundColor={hover() ? themeV2.raise(themeV2.background.default) : themeV2.background.default}
       customBorderChars={SplitBorder.customBorderChars}
-      borderColor={themeV2.background()}
+      borderColor={themeV2.background.default}
       onMouseOver={() => props.onClick && setHover(true)}
       onMouseOut={() => setHover(false)}
       onMouseUp={() => {
@@ -2394,10 +2396,12 @@ function BlockTool(props: {
               <Show
                 when={props.spinner}
                 fallback={
-                  <text fg={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>{title()}</text>
+                  <text fg={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}>
+                    {title()}
+                  </text>
                 }
               >
-                <Spinner color={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>
+                <Spinner color={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}>
                   {title().replace(/^# /, "")}
                 </Spinner>
               </Show>
@@ -2410,26 +2414,26 @@ function BlockTool(props: {
             <Show
               when={props.spinner}
               fallback={
-                <text flexShrink={0} fg={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>
+                <text flexShrink={0} fg={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}>
                   {path().label}
                 </text>
               }
             >
-              <Spinner color={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>
+              <Spinner color={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}>
                 {path().label.replace(/^# /, "")}
               </Spinner>
             </Show>
             <FilePath
               value={path().value}
               maxWidth={Math.max(2, ctx.width - 4 - stringWidth(path().label) - (props.spinner ? 2 : 0))}
-              fg={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}
+              fg={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}
             />
           </box>
         )}
       </Show>
       {props.children}
       <Show when={error()}>
-        <text fg={themeV2.text.feedback.error()}>{error()}</text>
+        <text fg={themeV2.text.feedback.error.default}>{error()}</text>
       </Show>
     </box>
   )
@@ -2444,7 +2448,7 @@ function Shell(props: ToolProps) {
     const request = data.session.permission.list(ctx.sessionID)?.[0]
     return request?.source?.type === "tool" && request.source.callID === props.part.id
   })
-  const color = createMemo(() => (permission() ? themeV2.text.feedback.warning() : themeV2.text()))
+  const color = createMemo(() => (permission() ? themeV2.text.feedback.warning.default : themeV2.text.default))
   const shellID = createMemo(() => stringValue(props.metadata.shellID))
   const backgroundRunning = createMemo(() => {
     const id = shellID()
@@ -2506,7 +2510,7 @@ function Shell(props: ToolProps) {
             isRunning() || props.part.state.status === "streaming" ? (
               <Spinner color={color()}>Writing command...</Spinner>
             ) : (
-              <text fg={themeV2.text.subdued()}>Writing command...</text>
+              <text fg={themeV2.text.subdued}>Writing command...</text>
             )
           }
         >
@@ -2514,14 +2518,14 @@ function Shell(props: ToolProps) {
             when={isRunning()}
             fallback={
               <text>
-                <span style={{ fg: themeV2.text() }}>{limited().slice(0, input().length)}</span>
-                <span style={{ fg: themeV2.text.subdued() }}>{limited().slice(input().length)}</span>
+                <span style={{ fg: themeV2.text.default }}>{limited().slice(0, input().length)}</span>
+                <span style={{ fg: themeV2.text.subdued }}>{limited().slice(input().length)}</span>
               </text>
             }
           >
             <Spinner color={color()}>
-              <span style={{ fg: themeV2.text() }}>{limited().slice(0, input().length)}</span>
-              <span style={{ fg: themeV2.text.subdued() }}>{limited().slice(input().length)}</span>
+              <span style={{ fg: themeV2.text.default }}>{limited().slice(0, input().length)}</span>
+              <span style={{ fg: themeV2.text.subdued }}>{limited().slice(input().length)}</span>
             </Spinner>
           </Show>
         </Show>
@@ -2547,10 +2551,10 @@ function Write(props: ToolProps) {
           path={{ label: "# Wrote", value: pathFormatter.format(stringValue(props.input.path)) }}
           part={props.part}
         >
-          <line_number fg={themeV2.text.subdued()} minWidth={3} paddingRight={1}>
+          <line_number fg={themeV2.text.subdued} minWidth={3} paddingRight={1}>
             <code
               conceal={false}
-              fg={themeV2.text()}
+              fg={themeV2.text.default}
               filetype={filetype(stringValue(props.input.path))}
               syntaxStyle={syntax()}
               content={code()}
@@ -2605,7 +2609,7 @@ function Read(props: ToolProps) {
       <For each={loaded()}>
         {(filepath) => (
           <box paddingLeft={3}>
-            <text paddingLeft={3} fg={themeV2.text.subdued()}>
+            <text paddingLeft={3} fg={themeV2.text.subdued}>
               ↳ Loaded {pathFormatter.format(filepath)}
             </text>
           </box>
@@ -2724,7 +2728,7 @@ function Execute(props: ToolProps) {
     <>
       <InlineTool
         icon={hasRuntimeError() ? "✗" : props.part.state.status === "completed" ? "✓" : "│"}
-        color={hasRuntimeError() ? themeV2.text.feedback.error() : undefined}
+        color={hasRuntimeError() ? themeV2.text.feedback.error.default : undefined}
         spinner={isLoading()}
         pending="execute"
         complete={true}
@@ -2736,7 +2740,7 @@ function Execute(props: ToolProps) {
         <box paddingLeft={3}>
           <For each={outputPreview().split("\n")}>
             {(line, index) => (
-              <text paddingLeft={3} fg={themeV2.text.feedback.error()}>
+              <text paddingLeft={3} fg={themeV2.text.feedback.error.default}>
                 {index() === 0 ? "↳ " : "  "}
                 {line}
               </text>
@@ -2778,16 +2782,16 @@ function Edit(props: ToolProps) {
                 showLineNumbers={true}
                 width="100%"
                 wrapMode={ctx.diffWrapMode()}
-                fg={themeV2.text()}
-                addedBg={themeV2.diff.background.added()}
-                removedBg={themeV2.diff.background.removed()}
-                contextBg={themeV2.diff.background.context()}
-                addedSignColor={themeV2.diff.highlight.added()}
-                removedSignColor={themeV2.diff.highlight.removed()}
-                lineNumberFg={themeV2.diff.lineNumber.text()}
-                lineNumberBg={themeV2.diff.background.context()}
-                addedLineNumberBg={themeV2.diff.lineNumber.background.added()}
-                removedLineNumberBg={themeV2.diff.lineNumber.background.removed()}
+                fg={themeV2.text.default}
+                addedBg={themeV2.diff.background.added}
+                removedBg={themeV2.diff.background.removed}
+                contextBg={themeV2.diff.background.context}
+                addedSignColor={themeV2.diff.highlight.added}
+                removedSignColor={themeV2.diff.highlight.removed}
+                lineNumberFg={themeV2.diff.lineNumber.text}
+                lineNumberBg={themeV2.diff.background.context}
+                addedLineNumberBg={themeV2.diff.lineNumber.background.added}
+                removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
               />
             </box>
             <Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.path) ?? ""} />
@@ -2852,7 +2856,7 @@ function ApplyPatch(props: ToolProps) {
                 <Show
                   when={file.type !== "delete"}
                   fallback={
-                    <text fg={themeV2.diff.text.removed()}>
+                    <text fg={themeV2.diff.text.removed}>
                       -{file.deletions} line{file.deletions !== 1 ? "s" : ""}
                     </text>
                   }
@@ -2866,16 +2870,16 @@ function ApplyPatch(props: ToolProps) {
                       showLineNumbers={true}
                       width="100%"
                       wrapMode={ctx.diffWrapMode()}
-                      fg={themeV2.text()}
-                      addedBg={themeV2.diff.background.added()}
-                      removedBg={themeV2.diff.background.removed()}
-                      contextBg={themeV2.diff.background.context()}
-                      addedSignColor={themeV2.diff.highlight.added()}
-                      removedSignColor={themeV2.diff.highlight.removed()}
-                      lineNumberFg={themeV2.diff.lineNumber.text()}
-                      lineNumberBg={themeV2.diff.background.context()}
-                      addedLineNumberBg={themeV2.diff.lineNumber.background.added()}
-                      removedLineNumberBg={themeV2.diff.lineNumber.background.removed()}
+                      fg={themeV2.text.default}
+                      addedBg={themeV2.diff.background.added}
+                      removedBg={themeV2.diff.background.removed}
+                      contextBg={themeV2.diff.background.context}
+                      addedSignColor={themeV2.diff.highlight.added}
+                      removedSignColor={themeV2.diff.highlight.removed}
+                      lineNumberFg={themeV2.diff.lineNumber.text}
+                      lineNumberBg={themeV2.diff.background.context}
+                      addedLineNumberBg={themeV2.diff.lineNumber.background.added}
+                      removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
                     />
                   </box>
                 </Show>
@@ -2898,7 +2902,7 @@ function ApplyPatch(props: ToolProps) {
                 <FilePath
                   value={file.resource}
                   maxWidth={Math.max(2, ctx.width - 3)}
-                  fg={file.type === "delete" ? themeV2.diff.text.removed() : themeV2.text.subdued()}
+                  fg={file.type === "delete" ? themeV2.diff.text.removed : themeV2.text.subdued}
                 />
               </BlockTool>
             )}
@@ -2945,8 +2949,8 @@ function Question(props: ToolProps) {
             <For each={questions()}>
               {(q, i) => (
                 <box flexDirection="column">
-                  <text fg={themeV2.text.subdued()}>{q.question}</text>
-                  <text fg={themeV2.text()}>{format(answers()?.[i()])}</text>
+                  <text fg={themeV2.text.subdued}>{q.question}</text>
+                  <text fg={themeV2.text.default}>{format(answers()?.[i()])}</text>
                 </box>
               )}
             </For>
@@ -2987,7 +2991,7 @@ function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
       <box>
         <For each={errors()}>
           {(diagnostic) => (
-            <text fg={themeV2.text.feedback.error()}>
+            <text fg={themeV2.text.feedback.error.default}>
               Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
             </text>
           )}

+ 61 - 52
packages/tui/src/routes/session/permission.tsx

@@ -45,8 +45,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
           scrollAcceleration={scrollAcceleration()}
           verticalScrollbarOptions={{
             trackOptions: {
-              backgroundColor: themeV2.background(),
-              foregroundColor: themeV2.scrollbar(),
+              backgroundColor: themeV2.background.default,
+              foregroundColor: themeV2.scrollbar.default,
             },
           }}
         >
@@ -58,16 +58,16 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
             showLineNumbers={true}
             width="100%"
             wrapMode="word"
-            fg={themeV2.text()}
-            addedBg={themeV2.diff.background.added()}
-            removedBg={themeV2.diff.background.removed()}
-            contextBg={themeV2.diff.background.context()}
-            addedSignColor={themeV2.diff.highlight.added()}
-            removedSignColor={themeV2.diff.highlight.removed()}
-            lineNumberFg={themeV2.diff.lineNumber.text()}
-            lineNumberBg={themeV2.diff.background.context()}
-            addedLineNumberBg={themeV2.diff.lineNumber.background.added()}
-            removedLineNumberBg={themeV2.diff.lineNumber.background.removed()}
+            fg={themeV2.text.default}
+            addedBg={themeV2.diff.background.added}
+            removedBg={themeV2.diff.background.removed}
+            contextBg={themeV2.diff.background.context}
+            addedSignColor={themeV2.diff.highlight.added}
+            removedSignColor={themeV2.diff.highlight.removed}
+            lineNumberFg={themeV2.diff.lineNumber.text}
+            lineNumberBg={themeV2.diff.background.context}
+            addedLineNumberBg={themeV2.diff.lineNumber.background.added}
+            removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
           />
         </scrollbox>
       </Show>
@@ -76,7 +76,7 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
           when={props.patch}
           fallback={
             <box paddingLeft={1}>
-              <text fg={themeV2.text.subdued()}>No diff provided</text>
+              <text fg={themeV2.text.subdued}>No diff provided</text>
             </box>
           }
         >
@@ -86,8 +86,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
               scrollAcceleration={scrollAcceleration()}
               verticalScrollbarOptions={{
                 trackOptions: {
-                  backgroundColor: themeV2.background(),
-                  foregroundColor: themeV2.scrollbar(),
+                  backgroundColor: themeV2.background.default,
+                  foregroundColor: themeV2.scrollbar.default,
                 },
               }}
             >
@@ -97,7 +97,7 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
                 streaming={true}
                 syntaxStyle={syntax()}
                 content={patch()}
-                fg={themeV2.text.subdued()}
+                fg={themeV2.text.subdued}
               />
             </scrollbox>
           )}
@@ -140,7 +140,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
           body={
             <box paddingLeft={1} gap={1}>
               <For each={permissionAlwaysLines(props.request)}>
-                {(line, index) => <text fg={index() === 0 ? themeV2.text.subdued() : themeV2.text()}>{line}</text>}
+                {(line, index) => <text fg={index() === 0 ? themeV2.text.subdued : themeV2.text.default}>{line}</text>}
               </For>
             </box>
           }
@@ -192,9 +192,9 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
             ) : props.request.action === "external_directory" ? (
               <Show when={current.lines.length > 0}>
                 <box paddingLeft={1} gap={1}>
-                  <text fg={themeV2.text.subdued()}>Patterns</text>
+                  <text fg={themeV2.text.subdued}>Patterns</text>
                   <box>
-                    <For each={current.lines}>{(line) => <text fg={themeV2.text()}>{line}</text>}</For>
+                    <For each={current.lines}>{(line) => <text fg={themeV2.text.default}>{line}</text>}</For>
                   </box>
                 </box>
               </Show>
@@ -207,8 +207,8 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
                         props.request.action === "shell" ||
                         props.request.action === "subagent" ||
                         props.request.action === "task"
-                          ? themeV2.text()
-                          : themeV2.text.subdued()
+                          ? themeV2.text.default
+                          : themeV2.text.subdued
                       }
                     >
                       {line}
@@ -221,15 +221,15 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
           const header = () => (
             <box flexDirection="column" gap={0}>
               <box flexDirection="row" gap={1} flexShrink={0}>
-                <text fg={themeV2.text.feedback.warning()}>{"△"}</text>
-                <text fg={themeV2.text()}>Permission required</text>
+                <text fg={themeV2.text.feedback.warning.default}>{"△"}</text>
+                <text fg={themeV2.text.default}>Permission required</text>
               </box>
               <Show when={props.request.action !== "shell" && current.title}>
                 <box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
-                  <text fg={themeV2.text.subdued()} flexShrink={0}>
+                  <text fg={themeV2.text.subdued} flexShrink={0}>
                     {current.icon}
                   </text>
-                  <text fg={themeV2.text()}>{current.title}</text>
+                  <text fg={themeV2.text.default}>{current.title}</text>
                 </box>
               </Show>
             </box>
@@ -329,18 +329,18 @@ function RejectPrompt(props: {
         role: "dialog",
         label: `Reject permission: ${props.action}`,
       }))}
-      backgroundColor={themeV2.background()}
+      backgroundColor={themeV2.background.default}
       border={["left"]}
-      borderColor={themeV2.text.feedback.error()}
+      borderColor={themeV2.text.feedback.error.default}
       customBorderChars={SplitBorder.customBorderChars}
     >
       <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
         <box flexDirection="row" gap={1} paddingLeft={1}>
-          <text fg={themeV2.text.feedback.error()}>{"△"}</text>
-          <text fg={themeV2.text()}>Reject permission</text>
+          <text fg={themeV2.text.feedback.error.default}>{"△"}</text>
+          <text fg={themeV2.text.default}>Reject permission</text>
         </box>
         <box paddingLeft={1}>
-          <text fg={themeV2.text.subdued()}>Tell OpenCode what to do differently</text>
+          <text fg={themeV2.text.subdued}>Tell OpenCode what to do differently</text>
         </box>
       </box>
       <box
@@ -350,7 +350,7 @@ function RejectPrompt(props: {
         paddingLeft={2}
         paddingRight={3}
         paddingBottom={1}
-        backgroundColor={themeV2.raise(themeV2.background())}
+        backgroundColor={themeV2.raise(themeV2.background.default)}
         justifyContent={narrow() ? "flex-start" : "space-between"}
         alignItems={narrow() ? "flex-start" : "center"}
         gap={1}
@@ -369,9 +369,9 @@ function RejectPrompt(props: {
             val.traits = { status: "REJECT" }
           }}
           focused
-          textColor={themeV2.text()}
-          focusedTextColor={themeV2.text()}
-          cursorColor={themeV2.text()}
+          textColor={themeV2.text.default}
+          focusedTextColor={themeV2.text.default}
+          cursorColor={themeV2.text.default}
         />
         <box
           id="session.permission.reject.actions"
@@ -394,8 +394,8 @@ function RejectPrompt(props: {
             }))}
             onMouseUp={() => props.onConfirm(input.plainText)}
           >
-            <text fg={themeV2.text()}>
-              enter <span style={{ fg: themeV2.text.subdued() }}>confirm</span>
+            <text fg={themeV2.text.default}>
+              enter <span style={{ fg: themeV2.text.subdued }}>confirm</span>
             </text>
           </box>
           <box
@@ -408,8 +408,8 @@ function RejectPrompt(props: {
             }))}
             onMouseUp={props.onCancel}
           >
-            <text fg={themeV2.text()}>
-              esc <span style={{ fg: themeV2.text.subdued() }}>cancel</span>
+            <text fg={themeV2.text.default}>
+              esc <span style={{ fg: themeV2.text.subdued }}>cancel</span>
             </text>
           </box>
         </box>
@@ -534,9 +534,9 @@ function Prompt<const T extends Record<string, string>>(props: {
         label: props.semanticLabel ?? props.title,
         expanded: store.expanded,
       }))}
-      backgroundColor={themeV2.background()}
+      backgroundColor={themeV2.background.default}
       border={["left"]}
-      borderColor={themeV2.background.action("focused")}
+      borderColor={themeV2.background.action.primary.focused}
       customBorderChars={SplitBorder.customBorderChars}
       {...(store.expanded
         ? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
@@ -554,8 +554,8 @@ function Prompt<const T extends Record<string, string>>(props: {
           when={props.header}
           fallback={
             <box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
-              <text fg={themeV2.text.feedback.warning()}>{"△"}</text>
-              <text fg={themeV2.text()}>{props.title}</text>
+              <text fg={themeV2.text.feedback.warning.default}>{"△"}</text>
+              <text fg={themeV2.text.default}>{props.title}</text>
             </box>
           }
         >
@@ -573,7 +573,7 @@ function Prompt<const T extends Record<string, string>>(props: {
         paddingLeft={2}
         paddingRight={3}
         paddingBottom={1}
-        backgroundColor={themeV2.raise(themeV2.background())}
+        backgroundColor={themeV2.raise(themeV2.background.default)}
         justifyContent={narrow() ? "flex-start" : "space-between"}
         alignItems={narrow() ? "flex-start" : "center"}
       >
@@ -602,14 +602,24 @@ function Prompt<const T extends Record<string, string>>(props: {
                 }))}
                 paddingLeft={1}
                 paddingRight={1}
-                backgroundColor={themeV2.background.action(option === store.selected ? "focused" : "default")}
+                backgroundColor={
+                  option === store.selected
+                    ? themeV2.background.action.primary.focused
+                    : themeV2.background.action.primary.default
+                }
                 onMouseOver={() => setStore("selected", option)}
                 onMouseUp={() => {
                   setStore("selected", option)
                   props.onSelect(option)
                 }}
               >
-                <text fg={themeV2.text.action(option === store.selected ? "focused" : "default")}>
+                <text
+                  fg={
+                    option === store.selected
+                      ? themeV2.text.action.primary.focused
+                      : themeV2.text.action.primary.default
+                  }
+                >
                   {props.options[option]}
                 </text>
               </box>
@@ -618,16 +628,15 @@ function Prompt<const T extends Record<string, string>>(props: {
         </box>
         <box flexDirection="row" gap={2} flexShrink={0}>
           <Show when={props.fullscreen}>
-            <text fg={themeV2.text()}>
-              {shortcuts.get("permission.prompt.fullscreen")}{" "}
-              <span style={{ fg: themeV2.text.subdued() }}>{hint()}</span>
+            <text fg={themeV2.text.default}>
+              {shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: themeV2.text.subdued }}>{hint()}</span>
             </text>
           </Show>
-          <text fg={themeV2.text()}>
-            {"⇆"} <span style={{ fg: themeV2.text.subdued() }}>select</span>
+          <text fg={themeV2.text.default}>
+            {"⇆"} <span style={{ fg: themeV2.text.subdued }}>select</span>
           </text>
-          <text fg={themeV2.text()}>
-            enter <span style={{ fg: themeV2.text.subdued() }}>confirm</span>
+          <text fg={themeV2.text.default}>
+            enter <span style={{ fg: themeV2.text.subdued }}>confirm</span>
           </text>
         </box>
       </box>

+ 5 - 5
packages/tui/src/routes/session/sidebar.tsx

@@ -18,7 +18,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
   return (
     <Show when={session()}>
       <box
-        backgroundColor={themeV2.background()}
+        backgroundColor={themeV2.background.default}
         width={42}
         height="100%"
         paddingTop={1}
@@ -32,8 +32,8 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
           scrollAcceleration={scrollAcceleration()}
           verticalScrollbarOptions={{
             trackOptions: {
-              backgroundColor: themeV2.background(),
-              foregroundColor: themeV2.scrollbar(),
+              backgroundColor: themeV2.background.default,
+              foregroundColor: themeV2.scrollbar.default,
             },
           }}
         >
@@ -45,11 +45,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
               title={session()!.title}
             >
               <box paddingRight={1}>
-                <text fg={themeV2.text()}>
+                <text fg={themeV2.text.default}>
                   <b>{session()!.title}</b>
                 </text>
                 <Show when={session()!.location.workspaceID}>
-                  <text fg={themeV2.text.subdued()}>{session()!.location.workspaceID}</text>
+                  <text fg={themeV2.text.subdued}>{session()!.location.workspaceID}</text>
                 </Show>
               </box>
             </pluginRuntime.Slot>

+ 19 - 13
packages/tui/src/routes/session/subagent-footer.tsx

@@ -61,18 +61,18 @@ export function SubagentFooter() {
         paddingRight={1}
         {...SplitBorder}
         border={["left"]}
-        borderColor={themeV2.border()}
+        borderColor={themeV2.border.default}
         flexShrink={0}
-        backgroundColor={themeV2.background()}
+        backgroundColor={themeV2.background.default}
       >
         <box flexDirection="row" justifyContent="space-between" gap={1}>
           <box flexDirection="row" gap={1}>
-            <text fg={themeV2.text()}>
+            <text fg={themeV2.text.default}>
               <b>{subagentInfo()}</b>
             </text>
             <Show when={usage()}>
               {(item) => (
-                <text fg={themeV2.text.subdued()} wrapMode="none">
+                <text fg={themeV2.text.subdued} wrapMode="none">
                   {[item().context, item().cost].filter(Boolean).join(" · ")}
                 </text>
               )}
@@ -83,30 +83,36 @@ export function SubagentFooter() {
               onMouseOver={() => setHover("parent")}
               onMouseOut={() => setHover(null)}
               onMouseUp={() => keymap.dispatch("session.parent")}
-              backgroundColor={hover() === "parent" ? themeV2.background.action("hovered") : themeV2.background()}
+              backgroundColor={
+                hover() === "parent" ? themeV2.background.action.primary.hovered : themeV2.background.default
+              }
             >
-              <text fg={themeV2.text()}>
-                Parent <span style={{ fg: themeV2.text.subdued() }}>{shortcuts.get("session.parent")}</span>
+              <text fg={themeV2.text.default}>
+                Parent <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.parent")}</span>
               </text>
             </box>
             <box
               onMouseOver={() => setHover("prev")}
               onMouseOut={() => setHover(null)}
               onMouseUp={() => keymap.dispatch("session.child.previous")}
-              backgroundColor={hover() === "prev" ? themeV2.background.action("hovered") : themeV2.background()}
+              backgroundColor={
+                hover() === "prev" ? themeV2.background.action.primary.hovered : themeV2.background.default
+              }
             >
-              <text fg={themeV2.text()}>
-                Prev <span style={{ fg: themeV2.text.subdued() }}>{shortcuts.get("session.child.previous")}</span>
+              <text fg={themeV2.text.default}>
+                Prev <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.child.previous")}</span>
               </text>
             </box>
             <box
               onMouseOver={() => setHover("next")}
               onMouseOut={() => setHover(null)}
               onMouseUp={() => keymap.dispatch("session.child.next")}
-              backgroundColor={hover() === "next" ? themeV2.background.action("hovered") : themeV2.background()}
+              backgroundColor={
+                hover() === "next" ? themeV2.background.action.primary.hovered : themeV2.background.default
+              }
             >
-              <text fg={themeV2.text()}>
-                Next <span style={{ fg: themeV2.text.subdued() }}>{shortcuts.get("session.child.next")}</span>
+              <text fg={themeV2.text.default}>
+                Next <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.child.next")}</span>
               </text>
             </box>
           </box>

+ 26 - 126
packages/tui/src/theme/v2/component.ts

@@ -1,140 +1,40 @@
 import type { RGBA } from "@opentui/core"
 import type { Accessor } from "solid-js"
-import type { ActionVariant, Mode, ResolvedActionState, ResolvedThemeView } from "./index"
-import { ActionState, HueStep } from "./schema"
-
-type StateFlags = Partial<Record<ActionState, boolean>>
+import type { Mode, ResolvedThemeView } from "./index"
 
 export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode: Accessor<Mode>) {
-  const textAction = actions((variant, state) => current().text.action[variant][state])
-  const backgroundAction = actions((variant, state) => current().background.action[variant][state])
-  const textFormfield = formfield((state) => current().text.formfield[state])
-  const backgroundFormfield = formfield((state) => current().background.formfield[state])
-  const hue = {
-    gray: (step: HueStep) => current().hue.gray[step],
-    red: (step: HueStep) => current().hue.red[step],
-    orange: (step: HueStep) => current().hue.orange[step],
-    yellow: (step: HueStep) => current().hue.yellow[step],
-    green: (step: HueStep) => current().hue.green[step],
-    cyan: (step: HueStep) => current().hue.cyan[step],
-    blue: (step: HueStep) => current().hue.blue[step],
-    purple: (step: HueStep) => current().hue.purple[step],
-    accent: (step: HueStep) => current().hue.accent[step],
-    interactive: (step: HueStep) => current().hue.interactive[step],
-    neutral: (step: HueStep) => current().hue.neutral[step],
-  }
-  const text = Object.assign(() => current().text.default, {
-    subdued: () => current().text.subdued,
-    action: textAction,
-    formfield: textFormfield,
-    feedback: {
-      error: feedbackText("error"),
-      warning: feedbackText("warning"),
-      success: feedbackText("success"),
-      info: feedbackText("info"),
+  return {
+    get hue() {
+      return current().hue
     },
-  })
-  const background = Object.assign(() => current().background.default, {
-    surface: {
-      offset: () => current().background.surface.offset,
-      overlay: () => current().background.surface.overlay,
+    get categorical() {
+      return current().categorical
     },
-    action: backgroundAction,
-    formfield: backgroundFormfield,
-    feedback: {
-      error: () => current().background.feedback.error.default,
-      warning: () => current().background.feedback.warning.default,
-      success: () => current().background.feedback.success.default,
-      info: () => current().background.feedback.info.default,
+    get text() {
+      return current().text
+    },
+    get background() {
+      return current().background
+    },
+    get border() {
+      return current().border
+    },
+    get scrollbar() {
+      return current().scrollbar
+    },
+    get diff() {
+      return current().diff
+    },
+    get syntax() {
+      return current().syntax
+    },
+    get markdown() {
+      return current().markdown
     },
-  })
-  const markdown = Object.assign(() => current().markdown.text, {
-    heading: () => current().markdown.heading,
-    link: () => current().markdown.link,
-    linkText: () => current().markdown.linkText,
-    code: () => current().markdown.code,
-    blockQuote: () => current().markdown.blockQuote,
-    emphasis: () => current().markdown.emphasis,
-    strong: () => current().markdown.strong,
-    horizontalRule: () => current().markdown.horizontalRule,
-    listItem: () => current().markdown.listItem,
-    listEnumeration: () => current().markdown.listEnumeration,
-    image: () => current().markdown.image,
-    imageText: () => current().markdown.imageText,
-    codeBlock: () => current().markdown.codeBlock,
-  })
-
-  function feedbackText(kind: "error" | "warning" | "success" | "info") {
-    return Object.assign(() => current().text.feedback[kind].default, {
-      subdued: () => current().text.feedback[kind].subdued,
-    })
-  }
-
-  return {
-    hue,
-    categorical: (step: HueStep) => current().categorical.map((scale) => scale[step]),
     source: (color: RGBA) => current().source(color),
     increase: (color: RGBA, amount = 1) => current().increase(color, amount),
     decrease: (color: RGBA, amount = 1) => current().decrease(color, amount),
     raise: (color: RGBA) => (mode() === "light" ? current().increase(color) : current().decrease(color)),
-    text,
-    background,
-    border: () => current().border.default,
-    scrollbar: () => current().scrollbar.default,
-    diff: {
-      text: {
-        added: () => current().diff.text.added,
-        removed: () => current().diff.text.removed,
-        context: () => current().diff.text.context,
-        hunkHeader: () => current().diff.text.hunkHeader,
-      },
-      background: {
-        added: () => current().diff.background.added,
-        removed: () => current().diff.background.removed,
-        context: () => current().diff.background.context,
-      },
-      highlight: {
-        added: () => current().diff.highlight.added,
-        removed: () => current().diff.highlight.removed,
-      },
-      lineNumber: {
-        text: () => current().diff.lineNumber.text,
-        background: {
-          added: () => current().diff.lineNumber.background.added,
-          removed: () => current().diff.lineNumber.background.removed,
-        },
-      },
-    },
-    syntax: {
-      comment: () => current().syntax.comment,
-      keyword: () => current().syntax.keyword,
-      function: () => current().syntax.function,
-      variable: () => current().syntax.variable,
-      string: () => current().syntax.string,
-      number: () => current().syntax.number,
-      type: () => current().syntax.type,
-      operator: () => current().syntax.operator,
-      punctuation: () => current().syntax.punctuation,
-    },
-    markdown,
-  }
-}
-
-function actions(get: (variant: ActionVariant, state: ResolvedActionState) => RGBA) {
-  const primary = stateful((state) => get("primary", state))
-  return Object.assign(primary, {
-    destructive: stateful((state) => get("destructive", state)),
-  })
-}
-
-function formfield(get: (state: ResolvedActionState) => RGBA) {
-  return stateful(get)
-}
-
-function stateful(get: (state: ResolvedActionState) => RGBA) {
-  return (states: ActionState | "default" | StateFlags = "default") => {
-    if (typeof states === "string") return get(states)
-    return get(ActionState.literals.find((state) => states[state]) ?? "default")
   }
 }
 

+ 5 - 5
packages/tui/src/ui/dialog-alert.tsx

@@ -30,27 +30,27 @@ export function DialogAlert(props: DialogAlertProps) {
   return (
     <box paddingLeft={2} paddingRight={2} gap={1}>
       <box flexDirection="row" justifyContent="space-between">
-        <text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
+        <text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
           {props.title}
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
+        <text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
           esc
         </text>
       </box>
       <box paddingBottom={1}>
-        <text fg={themeV2.text.subdued()}>{props.message}</text>
+        <text fg={themeV2.text.subdued}>{props.message}</text>
       </box>
       <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
         <box
           paddingLeft={3}
           paddingRight={3}
-          backgroundColor={themeV2.background.action("focused")}
+          backgroundColor={themeV2.background.action.primary.focused}
           onMouseUp={() => {
             props.onConfirm?.()
             dialog.clear()
           }}
         >
-          <text fg={themeV2.text.action("focused")}>ok</text>
+          <text fg={themeV2.text.action.primary.focused}>ok</text>
         </box>
       </box>
     </box>

+ 5 - 5
packages/tui/src/ui/dialog-confirm.tsx

@@ -57,15 +57,15 @@ export function DialogConfirm(props: DialogConfirmProps) {
   return (
     <box paddingLeft={2} paddingRight={2} gap={1}>
       <box flexDirection="row" justifyContent="space-between">
-        <text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
+        <text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
           {props.title}
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
+        <text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
           esc
         </text>
       </box>
       <box paddingBottom={1}>
-        <text fg={themeV2.text.subdued()}>{props.message}</text>
+        <text fg={themeV2.text.subdued}>{props.message}</text>
       </box>
       <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
         <For each={["cancel", "confirm"] as const}>
@@ -73,14 +73,14 @@ export function DialogConfirm(props: DialogConfirmProps) {
             <box
               paddingLeft={1}
               paddingRight={1}
-              backgroundColor={key === store.active ? themeV2.background.action("focused") : undefined}
+              backgroundColor={key === store.active ? themeV2.background.action.primary.focused : undefined}
               onMouseUp={() => {
                 if (key === "confirm") props.onConfirm?.()
                 if (key === "cancel") props.onCancel?.()
                 dialog.clear()
               }}
             >
-              <text fg={key === store.active ? themeV2.text.action("focused") : themeV2.text.subdued()}>
+              <text fg={key === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
                 {Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
               </text>
             </box>

+ 79 - 27
packages/tui/src/ui/dialog-export-options.tsx

@@ -76,32 +76,38 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
   return (
     <box paddingLeft={2} paddingRight={2} gap={1}>
       <box flexDirection="row" justifyContent="space-between">
-        <text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
+        <text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
           Export session
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
+        <text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
           esc
         </text>
       </box>
       <box flexDirection="row" gap={1}>
-        <text fg={themeV2.text()}>Export as:</text>
+        <text fg={themeV2.text.default}>Export as:</text>
         <box flexDirection="row" gap={1}>
           <For each={["markdown", "json"] as const}>
             {(format) => (
               <box
                 paddingLeft={1}
                 paddingRight={1}
-                backgroundColor={themeV2.background.formfield({
-                  focused: store.active === format,
-                  selected: store.format === format,
-                })}
+                backgroundColor={
+                  store.active === format
+                    ? themeV2.background.formfield.focused
+                    : store.format === format
+                      ? themeV2.background.formfield.selected
+                      : themeV2.background.formfield.default
+                }
                 onMouseUp={() => selectFormat(format)}
               >
                 <text
-                  fg={themeV2.text.formfield({
-                    focused: store.active === format,
-                    selected: store.format === format,
-                  })}
+                  fg={
+                    store.active === format
+                      ? themeV2.text.formfield.focused
+                      : store.format === format
+                        ? themeV2.text.formfield.selected
+                        : themeV2.text.formfield.default
+                  }
                 >
                   {store.format === format ? "◉" : "○"} {format === "markdown" ? "Markdown" : "JSON"}
                 </text>
@@ -114,19 +120,38 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
         <box
           flexDirection="row"
           gap={1}
-          backgroundColor={themeV2.background.formfield({
-            focused: store.active === "thinking",
-            selected: store.thinking,
-          })}
+          backgroundColor={
+            store.active === "thinking"
+              ? themeV2.background.formfield.focused
+              : store.thinking
+                ? themeV2.background.formfield.selected
+                : themeV2.background.formfield.default
+          }
           onMouseUp={() => {
             setStore("active", "thinking")
             setStore("thinking", !store.thinking)
           }}
         >
-          <text fg={themeV2.text.formfield({ focused: store.active === "thinking", selected: store.thinking })}>
+          <text
+            fg={
+              store.active === "thinking"
+                ? themeV2.text.formfield.focused
+                : store.thinking
+                  ? themeV2.text.formfield.selected
+                  : themeV2.text.formfield.default
+            }
+          >
             {store.thinking ? "[x]" : "[ ]"}
           </text>
-          <text fg={themeV2.text.formfield({ focused: store.active === "thinking", selected: store.thinking })}>
+          <text
+            fg={
+              store.active === "thinking"
+                ? themeV2.text.formfield.focused
+                : store.thinking
+                  ? themeV2.text.formfield.selected
+                  : themeV2.text.formfield.default
+            }
+          >
             Include thinking
           </text>
         </box>
@@ -135,19 +160,38 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
         <box
           flexDirection="row"
           gap={1}
-          backgroundColor={themeV2.background.formfield({
-            focused: store.active === "debug",
-            selected: store.debug,
-          })}
+          backgroundColor={
+            store.active === "debug"
+              ? themeV2.background.formfield.focused
+              : store.debug
+                ? themeV2.background.formfield.selected
+                : themeV2.background.formfield.default
+          }
           onMouseUp={() => {
             setStore("active", "debug")
             setStore("debug", !store.debug)
           }}
         >
-          <text fg={themeV2.text.formfield({ focused: store.active === "debug", selected: store.debug })}>
+          <text
+            fg={
+              store.active === "debug"
+                ? themeV2.text.formfield.focused
+                : store.debug
+                  ? themeV2.text.formfield.selected
+                  : themeV2.text.formfield.default
+            }
+          >
             {store.debug ? "[x]" : "[ ]"}
           </text>
-          <text fg={themeV2.text.formfield({ focused: store.active === "debug", selected: store.debug })}>
+          <text
+            fg={
+              store.active === "debug"
+                ? themeV2.text.formfield.focused
+                : store.debug
+                  ? themeV2.text.formfield.selected
+                  : themeV2.text.formfield.default
+            }
+          >
             Events (debug)
           </text>
         </box>
@@ -156,18 +200,26 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
         <box
           paddingLeft={4}
           paddingRight={4}
-          backgroundColor={overlayTheme.background()}
+          backgroundColor={overlayTheme.background.default}
           onMouseUp={() => confirm("copy")}
         >
-          <text fg={overlayTheme.text()}>Copy</text>
+          <text fg={overlayTheme.text.default}>Copy</text>
         </box>
         <box
           paddingLeft={4}
           paddingRight={4}
-          backgroundColor={themeV2.background.action({ focused: store.active === "export" })}
+          backgroundColor={
+            store.active === "export"
+              ? themeV2.background.action.primary.focused
+              : themeV2.background.action.primary.default
+          }
           onMouseUp={() => confirm("export")}
         >
-          <text fg={themeV2.text.action({ focused: store.active === "export" })}>Export</text>
+          <text
+            fg={store.active === "export" ? themeV2.text.action.primary.focused : themeV2.text.action.primary.default}
+          >
+            Export
+          </text>
         </box>
       </box>
     </box>

+ 10 - 5
packages/tui/src/ui/dialog-export-result.tsx

@@ -27,19 +27,24 @@ export function DialogExportResult(props: { path: string; onClose?: () => void }
   return (
     <box paddingLeft={2} paddingRight={2} gap={1}>
       <box flexDirection="row" justifyContent="space-between">
-        <text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
+        <text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
           Session exported
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={close}>
+        <text fg={themeV2.text.subdued} onMouseUp={close}>
           esc
         </text>
       </box>
       <box>
-        <text fg={themeV2.text()}>{props.path}</text>
+        <text fg={themeV2.text.default}>{props.path}</text>
       </box>
       <box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
-        <box paddingLeft={3} paddingRight={3} backgroundColor={themeV2.background.action("focused")} onMouseUp={close}>
-          <text fg={themeV2.text.action("focused")}>Close</text>
+        <box
+          paddingLeft={3}
+          paddingRight={3}
+          backgroundColor={themeV2.background.action.primary.focused}
+          onMouseUp={close}
+        >
+          <text fg={themeV2.text.action.primary.focused}>Close</text>
         </box>
       </box>
     </box>

+ 5 - 5
packages/tui/src/ui/dialog-help.tsx

@@ -19,15 +19,15 @@ export function DialogHelp() {
   return (
     <box paddingLeft={2} paddingRight={2} gap={1}>
       <box flexDirection="row" justifyContent="space-between">
-        <text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
+        <text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
           Help
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
+        <text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
           esc/enter
         </text>
       </box>
       <box paddingBottom={1}>
-        <text fg={themeV2.text.subdued()}>
+        <text fg={themeV2.text.subdued}>
           Press {shortcuts.get("command.palette.show")} to see all available actions and commands in any context.
         </text>
       </box>
@@ -35,10 +35,10 @@ export function DialogHelp() {
         <box
           paddingLeft={3}
           paddingRight={3}
-          backgroundColor={themeV2.background.action("focused")}
+          backgroundColor={themeV2.background.action.primary.focused}
           onMouseUp={() => dialog.clear()}
         >
-          <text fg={themeV2.text.action("focused")}>ok</text>
+          <text fg={themeV2.text.action.primary.focused}>ok</text>
         </box>
       </box>
     </box>

+ 10 - 10
packages/tui/src/ui/dialog-prompt.tsx

@@ -74,10 +74,10 @@ export function DialogPrompt(props: DialogPromptProps) {
   return (
     <box paddingLeft={2} paddingRight={2} gap={1}>
       <box flexDirection="row" justifyContent="space-between">
-        <text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
+        <text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
           {props.title}
         </text>
-        <text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
+        <text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
           esc
         </text>
       </box>
@@ -91,20 +91,20 @@ export function DialogPrompt(props: DialogPromptProps) {
           }}
           initialValue={props.value}
           placeholder={props.placeholder ?? "Enter text"}
-          placeholderColor={themeV2.text.subdued()}
-          textColor={themeV2.text.formfield({ disabled: props.busy })}
-          focusedTextColor={themeV2.text.formfield({ disabled: props.busy })}
-          cursorColor={props.busy ? themeV2.background.formfield("disabled") : themeV2.text()}
+          placeholderColor={themeV2.text.subdued}
+          textColor={props.busy ? themeV2.text.formfield.disabled : themeV2.text.formfield.default}
+          focusedTextColor={props.busy ? themeV2.text.formfield.disabled : themeV2.text.formfield.default}
+          cursorColor={props.busy ? themeV2.background.formfield.disabled : themeV2.text.default}
         />
         <Show when={props.busy}>
-          <Spinner color={themeV2.text.subdued()}>{props.busyText ?? "Working..."}</Spinner>
+          <Spinner color={themeV2.text.subdued}>{props.busyText ?? "Working..."}</Spinner>
         </Show>
       </box>
       <box paddingBottom={1} gap={1} flexDirection="row">
-        <Show when={!props.busy} fallback={<text fg={themeV2.text.subdued()}>processing...</text>}>
+        <Show when={!props.busy} fallback={<text fg={themeV2.text.subdued}>processing...</text>}>
           <Show when={shortcuts.get("dialog.prompt.submit")}>
-            <text fg={themeV2.text()}>
-              {shortcuts.get("dialog.prompt.submit")} <span style={{ fg: themeV2.text.subdued() }}>submit</span>
+            <text fg={themeV2.text.default}>
+              {shortcuts.get("dialog.prompt.submit")} <span style={{ fg: themeV2.text.subdued }}>submit</span>
             </text>
           </Show>
         </Show>

+ 1 - 1
packages/tui/src/ui/dialog.tsx

@@ -59,7 +59,7 @@ export function Dialog(
         }}
         width={width()}
         maxWidth={dimensions().width - 2}
-        backgroundColor={themeV2.background()}
+        backgroundColor={themeV2.background.default}
         paddingTop={1}
       >
         {props.children}

+ 4 - 4
packages/tui/src/ui/toast.tsx

@@ -31,17 +31,17 @@ export function Toast() {
           paddingRight={2}
           paddingTop={1}
           paddingBottom={1}
-          backgroundColor={themeV2.background()}
-          borderColor={themeV2.text.feedback[current().variant]()}
+          backgroundColor={themeV2.background.default}
+          borderColor={themeV2.text.feedback[current().variant].default}
           border={["left", "right"]}
           customBorderChars={SplitBorder.customBorderChars}
         >
           <Show when={current().title}>
-            <text attributes={TextAttributes.BOLD} marginBottom={1} fg={themeV2.text()}>
+            <text attributes={TextAttributes.BOLD} marginBottom={1} fg={themeV2.text.default}>
               {current().title}
             </text>
           </Show>
-          <text fg={themeV2.text()} wrapMode="word" width="100%">
+          <text fg={themeV2.text.default} wrapMode="word" width="100%">
             {current().message}
           </text>
         </box>

+ 40 - 52
packages/tui/test/theme/v2/component.test.ts

@@ -7,7 +7,7 @@ import { resolveTheme } from "../../../src/theme/v2/resolve"
 import { selectTheme } from "../../../src/theme/v2/select"
 import type { ContextKey } from "../../../src/theme/v2"
 
-test("provides reactive property, variant, state, and context accessors", () => {
+test("provides reactive properties, states, contexts, and color operations", () => {
   const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
   const [mode, setMode] = createSignal<"light" | "dark">("light")
   const [context, setContext] = createSignal<ContextKey>()
@@ -16,69 +16,57 @@ test("provides reactive property, variant, state, and context accessors", () =>
     return key ? (resolved().contexts[key] ?? resolved()) : resolved()
   }, mode)
 
-  expect(theme.text()).toBe(resolved().text.default)
-  expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500])
-  expect(theme.hue.interactive(500)).toBe(resolved().hue.interactive[500])
-  expect(theme.hue.gray(200)).toBe(resolved().hue.gray[200])
-  expect(theme.categorical(500)).toEqual(resolved().categorical.map((scale) => scale[500]))
-  expect(theme.increase(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[400])
-  expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[400])
-  expect(theme.decrease(theme.hue.red(300), 2)).toBe(resolved().hue.red[100])
-  expect(theme.increase(theme.hue.red(900), 3)).toBe(resolved().hue.red[900])
-  expect(theme.decrease(theme.hue.red(100), 3)).toBe(resolved().hue.red[100])
-  expect(theme.source(theme.background.surface.offset())).toEqual({ hue: "neutral", step: 300 })
+  expect(theme.text.default).toBe(resolved().text.default)
+  expect(theme.hue.accent[500]).toBe(resolved().hue.accent[500])
+  expect(theme.hue.interactive[500]).toBe(resolved().hue.interactive[500])
+  expect(theme.hue.gray[200]).toBe(resolved().hue.gray[200])
+  expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
+  expect(theme.increase(theme.background.surface.offset, 1)).toBe(resolved().hue.neutral[400])
+  expect(theme.raise(theme.background.surface.offset)).toBe(resolved().hue.neutral[400])
+  expect(theme.decrease(theme.hue.red[300], 2)).toBe(resolved().hue.red[100])
+  expect(theme.increase(theme.hue.red[900], 3)).toBe(resolved().hue.red[900])
+  expect(theme.decrease(theme.hue.red[100], 3)).toBe(resolved().hue.red[100])
+  expect(theme.source(theme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 })
   const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts())
   expect(theme.source(equivalent)).toBeUndefined()
   expect(theme.increase(equivalent, 1)).toBe(equivalent)
   const unmatched = RGBA.fromInts(1, 2, 3)
   expect(theme.increase(unmatched, 1)).toBe(unmatched)
-  expect(theme.text.subdued()).toBe(resolved().text.subdued)
-  expect(theme.text.action()).toBe(resolved().text.action.primary.default)
-  expect(theme.text.action("hovered")).toBe(resolved().text.action.primary.hovered)
-  expect(theme.text.action("pressed")).toBe(resolved().text.action.primary.pressed)
-  expect(theme.text.action("selected")).toBe(resolved().text.action.primary.selected)
-  expect(theme.background.action("selected")).toBe(resolved().background.action.primary.selected)
-  expect(theme.background.action("hovered")).toBe(resolved().background.action.primary.hovered)
-  expect(theme.background.action({ selected: true })).toBe(resolved().background.action.primary.selected)
-  expect(theme.background.action({ selected: true, hovered: true })).toBe(resolved().background.action.primary.selected)
-  expect(theme.background.action({ focused: true, selected: true })).toBe(resolved().background.action.primary.focused)
-  expect(theme.background.action({ pressed: true, focused: true, selected: true })).toBe(
-    resolved().background.action.primary.pressed,
-  )
-  expect(theme.background.action({ disabled: true, pressed: true, focused: true, selected: true, hovered: true })).toBe(
-    resolved().background.action.primary.disabled,
-  )
-  expect(theme.background.action({ disabled: false, selected: false })).toBe(
-    resolved().background.action.primary.default,
-  )
-  expect(theme.background.action.destructive("disabled")).toBe(resolved().background.action.destructive.disabled)
-  expect(theme.background.formfield("hovered")).toBe(resolved().background.formfield.hovered)
-  expect(theme.background.formfield({ selected: true, hovered: true })).toBe(resolved().background.formfield.selected)
-  expect(theme.background.formfield({ focused: true, selected: true, hovered: true })).toBe(
-    resolved().background.formfield.focused,
-  )
-  expect(
-    theme.background.formfield({ disabled: true, pressed: true, focused: true, selected: true, hovered: true }),
-  ).toBe(resolved().background.formfield.disabled)
-  expect(theme.background.surface.offset()).toBe(resolved().background.surface.offset)
-  expect(theme.background.surface.overlay()).toBe(resolved().background.surface.overlay)
-  expect(theme.scrollbar()).toBe(resolved().scrollbar.default)
-  expect(theme.diff.text.added()).toBe(resolved().diff.text.added)
+  expect(theme.text.subdued).toBe(resolved().text.subdued)
+  expect(theme.text.action.primary.default).toBe(resolved().text.action.primary.default)
+  expect(theme.text.action.primary.hovered).toBe(resolved().text.action.primary.hovered)
+  expect(theme.text.action.primary.pressed).toBe(resolved().text.action.primary.pressed)
+  expect(theme.text.action.primary.selected).toBe(resolved().text.action.primary.selected)
+  expect(theme.background.action.primary.selected).toBe(resolved().background.action.primary.selected)
+  expect(theme.background.action.primary.hovered).toBe(resolved().background.action.primary.hovered)
+  expect(theme.background.action.primary.focused).toBe(resolved().background.action.primary.focused)
+  expect(theme.background.action.primary.pressed).toBe(resolved().background.action.primary.pressed)
+  expect(theme.background.action.primary.disabled).toBe(resolved().background.action.primary.disabled)
+  expect(theme.background.action.primary.default).toBe(resolved().background.action.primary.default)
+  expect(theme.background.action.destructive.disabled).toBe(resolved().background.action.destructive.disabled)
+  expect(theme.background.formfield.hovered).toBe(resolved().background.formfield.hovered)
+  expect(theme.background.formfield.selected).toBe(resolved().background.formfield.selected)
+  expect(theme.background.formfield.focused).toBe(resolved().background.formfield.focused)
+  expect(theme.background.formfield.disabled).toBe(resolved().background.formfield.disabled)
+  expect(theme.background.surface.offset).toBe(resolved().background.surface.offset)
+  expect(theme.background.surface.overlay).toBe(resolved().background.surface.overlay)
+  expect(theme.scrollbar.default).toBe(resolved().scrollbar.default)
+  expect(theme.diff.text.added).toBe(resolved().diff.text.added)
 
   setContext("@context:elevated")
-  expect(theme.categorical(500)).toEqual(resolved().categorical.map((scale) => scale[500]))
-  expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
-  expect(theme.background.action("focused")).toBe(
+  expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
+  expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default)
+  expect(theme.background.action.primary.focused).toBe(
     resolved().contexts["@context:elevated"]!.background.action.primary.focused,
   )
-  expect(theme.background.action("hovered")).toBe(resolved().background.surface.overlay)
-  expect(theme.background.formfield("selected")).toBe(
+  expect(theme.background.action.primary.hovered).toBe(resolved().background.surface.overlay)
+  expect(theme.background.formfield.selected).toBe(
     resolved().contexts["@context:elevated"]!.background.formfield.selected,
   )
 
   setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
   setMode("dark")
-  expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
-  expect(theme.decrease(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[600])
-  expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[600])
+  expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default)
+  expect(theme.decrease(theme.background.surface.offset, 1)).toBe(resolved().hue.neutral[600])
+  expect(theme.raise(theme.background.surface.offset)).toBe(resolved().hue.neutral[600])
 })