Jay V 8 месяцев назад
Родитель
Сommit
feeb49a42b

+ 2 - 1
packages/web/src/components/CodeBlock.tsx

@@ -6,6 +6,7 @@ import {
   createResource,
   createResource,
 } from "solid-js"
 } from "solid-js"
 import { codeToHtml } from "shiki"
 import { codeToHtml } from "shiki"
+import styles from "./codeblock.module.css"
 import { transformerNotationDiff } from "@shikijs/transformers"
 import { transformerNotationDiff } from "@shikijs/transformers"
 
 
 interface CodeBlockProps extends JSX.HTMLAttributes<HTMLDivElement> {
 interface CodeBlockProps extends JSX.HTMLAttributes<HTMLDivElement> {
@@ -37,7 +38,7 @@ function CodeBlock(props: CodeBlockProps) {
     }
     }
   })
   })
 
 
-  return <div ref={containerRef} {...rest}></div>
+  return <div ref={containerRef} class={styles.codeblock} {...rest}></div>
 }
 }
 
 
 export default CodeBlock
 export default CodeBlock

+ 112 - 7
packages/web/src/components/Share.tsx

@@ -18,11 +18,13 @@ import {
   IconSparkles,
   IconSparkles,
   IconUserCircle,
   IconUserCircle,
   IconChevronDown,
   IconChevronDown,
+  IconCommandLine,
   IconChevronRight,
   IconChevronRight,
   IconPencilSquare,
   IconPencilSquare,
   IconWrenchScrewdriver,
   IconWrenchScrewdriver,
 } from "./icons"
 } from "./icons"
 import DiffView from "./DiffView"
 import DiffView from "./DiffView"
+import CodeBlock from "./CodeBlock"
 import styles from "./share.module.css"
 import styles from "./share.module.css"
 import { type UIMessage } from "ai"
 import { type UIMessage } from "ai"
 import { createStore, reconcile } from "solid-js/store"
 import { createStore, reconcile } from "solid-js/store"
@@ -199,6 +201,70 @@ function TextPart(props: TextPartProps) {
   )
   )
 }
 }
 
 
+interface TerminalPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
+  text: string
+  expand?: boolean
+}
+function TerminalPart(props: TerminalPartProps) {
+  const [local, rest] = splitProps(props, ["text", "expand"])
+  const [expanded, setExpanded] = createSignal(false)
+  const [overflowed, setOverflowed] = createSignal(false)
+  let preEl: HTMLElement | undefined
+
+  function checkOverflow() {
+    if (!preEl) return
+
+    const code = preEl.getElementsByTagName("code")[0]
+
+    if (code && !local.expand) {
+      console.log(preEl.clientHeight, code.offsetHeight)
+      setOverflowed(preEl.clientHeight < code.offsetHeight)
+    }
+  }
+
+  onMount(() => {
+    checkOverflow()
+    window.addEventListener("resize", checkOverflow)
+  })
+
+  createEffect(() => {
+    local.text
+    setTimeout(checkOverflow, 0)
+  })
+
+  onCleanup(() => {
+    window.removeEventListener("resize", checkOverflow)
+  })
+
+  return (
+    <div
+      data-element-message-terminal
+      data-expanded={expanded() || local.expand === true}
+      {...rest}
+    >
+      <div data-section="body">
+        <div data-section="header"></div>
+        <div data-section="content">
+          <CodeBlock
+            lang="ansi"
+            ref={(el) => (preEl = el)}
+            code={`\x1b[90m>\x1b[0m ${local.text}`}
+          />
+        </div>
+      </div>
+      {overflowed() && (
+        <button
+          type="button"
+          data-element-button-text
+          onClick={() => setExpanded((e) => !e)}
+        >
+          {expanded() ? "Show less" : "Show more"}
+        </button>
+      )}
+    </div>
+  )
+}
+
 function PartFooter(props: { time: number }) {
 function PartFooter(props: { time: number }) {
   return (
   return (
     <span
     <span
@@ -478,7 +544,7 @@ export default function Share(props: { api: string }) {
                           {(part) => (
                           {(part) => (
                             <div data-section="part" data-part-type="user-text">
                             <div data-section="part" data-part-type="user-text">
                               <div data-section="decoration">
                               <div data-section="decoration">
-                                <div>
+                                <div title="Message">
                                   <IconUserCircle width={18} height={18} />
                                   <IconUserCircle width={18} height={18} />
                                 </div>
                                 </div>
                                 <div></div>
                                 <div></div>
@@ -505,7 +571,7 @@ export default function Share(props: { api: string }) {
                           {(part) => (
                           {(part) => (
                             <div data-section="part" data-part-type="ai-text">
                             <div data-section="part" data-part-type="ai-text">
                               <div data-section="decoration">
                               <div data-section="decoration">
-                                <div>
+                                <div title="AI response">
                                   <IconSparkles width={18} height={18} />
                                   <IconSparkles width={18} height={18} />
                                 </div>
                                 </div>
                                 <div></div>
                                 <div></div>
@@ -570,7 +636,7 @@ export default function Share(props: { api: string }) {
                               data-part-type="system-text"
                               data-part-type="system-text"
                             >
                             >
                               <div data-section="decoration">
                               <div data-section="decoration">
-                                <div>
+                                <div title="System message">
                                   <IconCpuChip width={18} height={18} />
                                   <IconCpuChip width={18} height={18} />
                                 </div>
                                 </div>
                                 <div></div>
                                 <div></div>
@@ -610,7 +676,7 @@ export default function Share(props: { api: string }) {
                                 data-part-type="tool-edit"
                                 data-part-type="tool-edit"
                               >
                               >
                                 <div data-section="decoration">
                                 <div data-section="decoration">
-                                  <div>
+                                  <div title="Edit file">
                                     <IconPencilSquare width={18} height={18} />
                                     <IconPencilSquare width={18} height={18} />
                                   </div>
                                   </div>
                                   <div></div>
                                   <div></div>
@@ -618,11 +684,12 @@ export default function Share(props: { api: string }) {
                                 <div data-section="content">
                                 <div data-section="content">
                                   <div data-part-tool-body>
                                   <div data-part-tool-body>
                                     <span data-part-title data-size="md">
                                     <span data-part-title data-size="md">
-                                      Edit {filePath}
+                                      <span data-element-label>Edit</span>
+                                      <b>{filePath}</b>
                                     </span>
                                     </span>
                                     <div data-part-tool-edit>
                                     <div data-part-tool-edit>
                                       <DiffView
                                       <DiffView
-                                        class={styles["code-block"]}
+                                        class={styles["diff-code-block"]}
                                         changes={metadata()?.changes || []}
                                         changes={metadata()?.changes || []}
                                         lang={getFileType(filePath)}
                                         lang={getFileType(filePath)}
                                       />
                                       />
@@ -634,6 +701,44 @@ export default function Share(props: { api: string }) {
                             )
                             )
                           }}
                           }}
                         </Match>
                         </Match>
+                        {/* Bash tool */}
+                        <Match
+                          when={
+                            msg.role === "assistant" &&
+                            part.type === "tool-invocation" &&
+                            part.toolInvocation.toolName === "opencode_bash" &&
+                            part
+                          }
+                        >
+                          {(part) => {
+                            const id = part().toolInvocation.toolCallId
+                            const command = part().toolInvocation.args.command
+                            const stdout = msg.metadata?.tool[id]?.stdout
+                            const result = stdout || (part().toolInvocation.state === "result" && part().toolInvocation.result)
+                            return (
+                              <div
+                                data-section="part"
+                                data-part-type="tool-edit"
+                              >
+                                <div data-section="decoration">
+                                  <div title="Bash command">
+                                    <IconCommandLine width={18} height={18} />
+                                  </div>
+                                  <div></div>
+                                </div>
+                                <div data-section="content">
+                                  <div data-part-tool-body>
+                                    <TerminalPart
+                                      data-size="sm"
+                                      text={command + (result ? `\n${result}` : "")}
+                                    />
+                                  </div>
+                                  <PartFooter time={time} />
+                                </div>
+                              </div>
+                            )
+                          }}
+                        </Match>
                         {/* Tool call */}
                         {/* Tool call */}
                         <Match
                         <Match
                           when={
                           when={
@@ -648,7 +753,7 @@ export default function Share(props: { api: string }) {
                               data-part-type="tool-fallback"
                               data-part-type="tool-fallback"
                             >
                             >
                               <div data-section="decoration">
                               <div data-section="decoration">
-                                <div>
+                                <div title="Tool call">
                                   <IconWrenchScrewdriver
                                   <IconWrenchScrewdriver
                                     width={18}
                                     width={18}
                                     height={18}
                                     height={18}

+ 7 - 0
packages/web/src/components/codeblock.module.css

@@ -0,0 +1,7 @@
+.codeblock {
+  pre {
+    --shiki-dark-bg: var(--sl-color-bg) !important;
+    background-color: var(--sl-color-bg) !important;
+  }
+}
+

+ 11 - 6
packages/web/src/components/diffview.module.css

@@ -9,9 +9,10 @@
 .column {
 .column {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
-  overflow-x: auto;
+
+  overflow-x: visible;
   min-width: 0;
   min-width: 0;
-  align-items: flex-start;
+  align-items: stretch;
 
 
   &:first-child {
   &:first-child {
     border-right: 1px solid var(--sl-color-divider);
     border-right: 1px solid var(--sl-color-divider);
@@ -28,13 +29,17 @@
 [data-section="cell"] {
 [data-section="cell"] {
   position: relative;
   position: relative;
   flex: none;
   flex: none;
-  width: max-content;
+
+  width: 100%;
   padding: 0.1875rem 0.5rem 0.1875rem 1.8ch;
   padding: 0.1875rem 0.5rem 0.1875rem 1.8ch;
   margin: 0;
   margin: 0;
 
 
   pre {
   pre {
+    --shiki-dark-bg: var(--sl-color-bg-surface) !important;
     background-color: var(--sl-color-bg-surface) !important;
     background-color: var(--sl-color-bg-surface) !important;
-    white-space: pre;
+
+    white-space: pre-wrap;
+    word-break: break-word;
 
 
     code > span:empty::before {
     code > span:empty::before {
       content: "\00a0";
       content: "\00a0";
@@ -47,9 +52,9 @@
 
 
 [data-diff-type="removed"] {
 [data-diff-type="removed"] {
   background-color: var(--sl-color-red-low);
   background-color: var(--sl-color-red-low);
-  min-width: 100%;
 
 
   pre {
   pre {
+    --shiki-dark-bg: var(--sl-color-red-low) !important;
     background-color: var(--sl-color-red-low) !important;
     background-color: var(--sl-color-red-low) !important;
   }
   }
 
 
@@ -64,9 +69,9 @@
 
 
 [data-diff-type="added"] {
 [data-diff-type="added"] {
   background-color: var(--sl-color-green-low);
   background-color: var(--sl-color-green-low);
-  min-width: 100%;
 
 
   pre {
   pre {
+    --shiki-dark-bg: var(--sl-color-green-low) !important;
     background-color: var(--sl-color-green-low) !important;
     background-color: var(--sl-color-green-low) !important;
   }
   }
 
 

+ 109 - 10
packages/web/src/components/share.module.css

@@ -4,6 +4,8 @@
   flex-direction: column;
   flex-direction: column;
   gap: 2.5rem;
   gap: 2.5rem;
   line-height: 1;
   line-height: 1;
+
+  --term-icon: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2060%2016'%20preserveAspectRatio%3D'xMidYMid%20meet'%3E%3Ccircle%20cx%3D'8'%20cy%3D'8'%20r%3D'8'%2F%3E%3Ccircle%20cx%3D'30'%20cy%3D'8'%20r%3D'8'%2F%3E%3Ccircle%20cx%3D'52'%20cy%3D'8'%20r%3D'8'%2F%3E%3C%2Fsvg%3E");
 }
 }
 
 
 [data-element-button-text] {
 [data-element-button-text] {
@@ -61,6 +63,14 @@
     display: flex;
     display: flex;
     align-items: center;
     align-items: center;
     justify-content: space-between;
     justify-content: space-between;
+    gap: 1rem;
+
+    h1 {
+      display: -webkit-box;
+      -webkit-box-orient: vertical;
+      -webkit-line-clamp: 2;
+      overflow: hidden;
+    }
   }
   }
 
 
   [data-section="row"] {
   [data-section="row"] {
@@ -107,7 +117,8 @@
     padding: 0;
     padding: 0;
     margin: 0;
     margin: 0;
     display: flex;
     display: flex;
-    gap: 1rem;
+    gap: 0.5rem 1rem;
+    flex-wrap: wrap;
 
 
     li {
     li {
       display: flex;
       display: flex;
@@ -173,6 +184,7 @@
     div:first-child {
     div:first-child {
       flex: 0 0 auto;
       flex: 0 0 auto;
       width: 18px;
       width: 18px;
+      opacity: 0.65;
       svg {
       svg {
         color: var(--sl-color-text-secondary);
         color: var(--sl-color-text-secondary);
         display: block;
         display: block;
@@ -203,6 +215,10 @@
       line-height: 18px;
       line-height: 18px;
       font-size: 0.75rem;
       font-size: 0.75rem;
 
 
+      b {
+        font-weight: 500;
+      }
+
       &[data-size="md"] {
       &[data-size="md"] {
         font-size: 0.875rem;
         font-size: 0.875rem;
       }
       }
@@ -258,6 +274,21 @@
       }
       }
     }
     }
   }
   }
+
+  [data-part-type="tool-edit"] {
+    [data-part-tool-body] {
+      gap: 0.5rem;
+    }
+    [data-part-title] {
+      display: flex;
+      align-items: center;
+      gap: 0.5rem;
+
+      b {
+        color: var(--sl-color-text);
+      }
+    }
+  }
 }
 }
 
 
 [data-element-message-text] {
 [data-element-message-text] {
@@ -269,14 +300,6 @@
   align-items: flex-start;
   align-items: flex-start;
   gap: 1rem;
   gap: 1rem;
 
 
-  pre {
-    line-height: 1.5;
-    font-size: 0.875rem;
-    white-space: pre-wrap;
-    overflow-wrap: anywhere;
-    color: var(--sl-color-text);
-  }
-
   &[data-size="sm"] {
   &[data-size="sm"] {
     pre {
     pre {
       font-size: 0.75rem;
       font-size: 0.75rem;
@@ -289,6 +312,14 @@
     }
     }
   }
   }
 
 
+  pre {
+    line-height: 1.5;
+    font-size: 0.875rem;
+    white-space: pre-wrap;
+    overflow-wrap: anywhere;
+    color: var(--sl-color-text);
+  }
+
   button {
   button {
     flex: 0 0 auto;
     flex: 0 0 auto;
     padding: 2px 0;
     padding: 2px 0;
@@ -327,7 +358,75 @@
   }
   }
 }
 }
 
 
-.code-block {
+[data-element-message-terminal] {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 0.5rem;
+
+  [data-section="body"] {
+    border: 1px solid var(--sl-color-divider);
+    border-radius: 0.25rem;
+
+    [data-section="header"] {
+      position: relative;
+      border-bottom: 1px solid var(--sl-color-divider);
+      width: 100%;
+      height: 25px;
+
+      &::before {
+        content: '';
+        position: absolute;
+        pointer-events: none;
+        top: 8px;
+        left: 10px;
+        width: 2rem;
+        height: 0.5rem;
+        line-height: 0;
+        background-color: var(--sl-color-hairline);
+        mask-image: var(--term-icon);
+        mask-repeat: no-repeat;
+      }
+    }
+  }
+
+  [data-section="content"] {
+    padding: 0.5rem calc(0.5rem + 3px);
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 1rem;
+
+    pre {
+      line-height: 1.6;
+      font-size: 0.75rem;
+      white-space: pre-wrap;
+      word-break: break-word;
+    }
+  }
+
+  &[data-expanded="true"] {
+    pre {
+      display: block;
+    }
+  }
+  &[data-expanded="false"] {
+    pre {
+      display: -webkit-box;
+      -webkit-box-orient: vertical;
+      -webkit-line-clamp: 7;
+      overflow: hidden;
+    }
+  }
+
+  button {
+    flex: 0 0 auto;
+    padding-left: 1px;
+    font-size: 0.75rem;
+  }
+}
+
+.diff-code-block {
   pre {
   pre {
     line-height: 1.25;
     line-height: 1.25;
     font-size: 0.75rem;
     font-size: 0.75rem;