summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-04 20:32:08 -0600
committerAdam <[email protected]>2025-12-04 20:32:08 -0600
commit09f522f0aa698be60c954e58bb7eee0e460c4439 (patch)
tree8b936f4ab3cbafab391551e898412d1617dbd66b /packages/desktop/src
parentd82bd430f68b8227a93c39e0b7b617c9463ceea8 (diff)
downloadopencode-09f522f0aa698be60c954e58bb7eee0e460c4439.tar.gz
opencode-09f522f0aa698be60c954e58bb7eee0e460c4439.zip
Reapply "feat(desktop): terminal pane (#5081)"
This reverts commit f9dcd979364acc5172fd0044c1c8b04dcaec9229.
Diffstat (limited to 'packages/desktop/src')
-rw-r--r--packages/desktop/src/addons/serialize.ts649
-rw-r--r--packages/desktop/src/components/terminal.tsx151
-rw-r--r--packages/desktop/src/context/layout.tsx22
-rw-r--r--packages/desktop/src/context/sdk.tsx2
-rw-r--r--packages/desktop/src/context/session.tsx100
-rw-r--r--packages/desktop/src/pages/layout.tsx114
-rw-r--r--packages/desktop/src/pages/session.tsx649
7 files changed, 1393 insertions, 294 deletions
diff --git a/packages/desktop/src/addons/serialize.ts b/packages/desktop/src/addons/serialize.ts
new file mode 100644
index 000000000..03899ff10
--- /dev/null
+++ b/packages/desktop/src/addons/serialize.ts
@@ -0,0 +1,649 @@
+/**
+ * SerializeAddon - Serialize terminal buffer contents
+ *
+ * Port of xterm.js addon-serialize for ghostty-web.
+ * Enables serialization of terminal contents to a string that can
+ * be written back to restore terminal state.
+ *
+ * Usage:
+ * ```typescript
+ * const serializeAddon = new SerializeAddon();
+ * term.loadAddon(serializeAddon);
+ * const content = serializeAddon.serialize();
+ * ```
+ */
+
+import type { ITerminalAddon, ITerminalCore, IBufferRange } from "ghostty-web"
+
+// ============================================================================
+// Buffer Types (matching ghostty-web internal interfaces)
+// ============================================================================
+
+interface IBuffer {
+ readonly type: "normal" | "alternate"
+ readonly cursorX: number
+ readonly cursorY: number
+ readonly viewportY: number
+ readonly baseY: number
+ readonly length: number
+ getLine(y: number): IBufferLine | undefined
+ getNullCell(): IBufferCell
+}
+
+interface IBufferLine {
+ readonly length: number
+ readonly isWrapped: boolean
+ getCell(x: number): IBufferCell | undefined
+ translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string
+}
+
+interface IBufferCell {
+ getChars(): string
+ getCode(): number
+ getWidth(): number
+ getFgColorMode(): number
+ getBgColorMode(): number
+ getFgColor(): number
+ getBgColor(): number
+ isBold(): number
+ isItalic(): number
+ isUnderline(): number
+ isStrikethrough(): number
+ isBlink(): number
+ isInverse(): number
+ isInvisible(): number
+ isFaint(): number
+ isDim(): boolean
+}
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface ISerializeOptions {
+ /**
+ * The row range to serialize. When an explicit range is specified, the cursor
+ * will get its final repositioning.
+ */
+ range?: ISerializeRange
+ /**
+ * The number of rows in the scrollback buffer to serialize, starting from
+ * the bottom of the scrollback buffer. When not specified, all available
+ * rows in the scrollback buffer will be serialized.
+ */
+ scrollback?: number
+ /**
+ * Whether to exclude the terminal modes from the serialization.
+ * Default: false
+ */
+ excludeModes?: boolean
+ /**
+ * Whether to exclude the alt buffer from the serialization.
+ * Default: false
+ */
+ excludeAltBuffer?: boolean
+}
+
+export interface ISerializeRange {
+ /**
+ * The line to start serializing (inclusive).
+ */
+ start: number
+ /**
+ * The line to end serializing (inclusive).
+ */
+ end: number
+}
+
+export interface IHTMLSerializeOptions {
+ /**
+ * The number of rows in the scrollback buffer to serialize, starting from
+ * the bottom of the scrollback buffer.
+ */
+ scrollback?: number
+ /**
+ * Whether to only serialize the selection.
+ * Default: false
+ */
+ onlySelection?: boolean
+ /**
+ * Whether to include the global background of the terminal.
+ * Default: false
+ */
+ includeGlobalBackground?: boolean
+ /**
+ * The range to serialize. This is prioritized over onlySelection.
+ */
+ range?: {
+ startLine: number
+ endLine: number
+ startCol: number
+ }
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+function constrain(value: number, low: number, high: number): number {
+ return Math.max(low, Math.min(value, high))
+}
+
+function equalFg(cell1: IBufferCell, cell2: IBufferCell): boolean {
+ return cell1.getFgColorMode() === cell2.getFgColorMode() && cell1.getFgColor() === cell2.getFgColor()
+}
+
+function equalBg(cell1: IBufferCell, cell2: IBufferCell): boolean {
+ return cell1.getBgColorMode() === cell2.getBgColorMode() && cell1.getBgColor() === cell2.getBgColor()
+}
+
+function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
+ return (
+ !!cell1.isInverse() === !!cell2.isInverse() &&
+ !!cell1.isBold() === !!cell2.isBold() &&
+ !!cell1.isUnderline() === !!cell2.isUnderline() &&
+ !!cell1.isBlink() === !!cell2.isBlink() &&
+ !!cell1.isInvisible() === !!cell2.isInvisible() &&
+ !!cell1.isItalic() === !!cell2.isItalic() &&
+ !!cell1.isDim() === !!cell2.isDim() &&
+ !!cell1.isStrikethrough() === !!cell2.isStrikethrough()
+ )
+}
+
+// ============================================================================
+// Base Serialize Handler
+// ============================================================================
+
+abstract class BaseSerializeHandler {
+ constructor(protected readonly _buffer: IBuffer) {}
+
+ public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
+ let oldCell = this._buffer.getNullCell()
+
+ const startRow = range.start.y
+ const endRow = range.end.y
+ const startColumn = range.start.x
+ const endColumn = range.end.x
+
+ this._beforeSerialize(endRow - startRow, startRow, endRow)
+
+ for (let row = startRow; row <= endRow; row++) {
+ const line = this._buffer.getLine(row)
+ if (line) {
+ const startLineColumn = row === range.start.y ? startColumn : 0
+ const endLineColumn = row === range.end.y ? endColumn : line.length
+ for (let col = startLineColumn; col < endLineColumn; col++) {
+ const c = line.getCell(col)
+ if (!c) {
+ continue
+ }
+ this._nextCell(c, oldCell, row, col)
+ oldCell = c
+ }
+ }
+ this._rowEnd(row, row === endRow)
+ }
+
+ this._afterSerialize()
+
+ return this._serializeString(excludeFinalCursorPosition)
+ }
+
+ protected _nextCell(_cell: IBufferCell, _oldCell: IBufferCell, _row: number, _col: number): void {}
+ protected _rowEnd(_row: number, _isLastRow: boolean): void {}
+ protected _beforeSerialize(_rows: number, _startRow: number, _endRow: number): void {}
+ protected _afterSerialize(): void {}
+ protected _serializeString(_excludeFinalCursorPosition?: boolean): string {
+ return ""
+ }
+}
+
+// ============================================================================
+// String Serialize Handler
+// ============================================================================
+
+class StringSerializeHandler extends BaseSerializeHandler {
+ private _rowIndex: number = 0
+ private _allRows: string[] = []
+ private _allRowSeparators: string[] = []
+ private _currentRow: string = ""
+ private _nullCellCount: number = 0
+ private _cursorStyle: IBufferCell
+ private _cursorStyleRow: number = 0
+ private _cursorStyleCol: number = 0
+ private _backgroundCell: IBufferCell
+ private _firstRow: number = 0
+ private _lastCursorRow: number = 0
+ private _lastCursorCol: number = 0
+ private _lastContentCursorRow: number = 0
+ private _lastContentCursorCol: number = 0
+ private _thisRowLastChar: IBufferCell
+ private _thisRowLastSecondChar: IBufferCell
+ private _nextRowFirstChar: IBufferCell
+
+ constructor(
+ buffer: IBuffer,
+ private readonly _terminal: ITerminalCore,
+ ) {
+ super(buffer)
+ this._cursorStyle = this._buffer.getNullCell()
+ this._backgroundCell = this._buffer.getNullCell()
+ this._thisRowLastChar = this._buffer.getNullCell()
+ this._thisRowLastSecondChar = this._buffer.getNullCell()
+ this._nextRowFirstChar = this._buffer.getNullCell()
+ }
+
+ protected _beforeSerialize(rows: number, start: number, _end: number): void {
+ this._allRows = new Array<string>(rows)
+ this._lastContentCursorRow = start
+ this._lastCursorRow = start
+ this._firstRow = start
+ }
+
+ protected _rowEnd(row: number, isLastRow: boolean): void {
+ // if there is colorful empty cell at line end, we must pad it back
+ if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {
+ this._currentRow += `\u001b[${this._nullCellCount}X`
+ }
+
+ let rowSeparator = ""
+
+ if (!isLastRow) {
+ // Enable BCE
+ if (row - this._firstRow >= this._terminal.rows) {
+ const line = this._buffer.getLine(this._cursorStyleRow)
+ const cell = line?.getCell(this._cursorStyleCol)
+ if (cell) {
+ this._backgroundCell = cell
+ }
+ }
+
+ const currentLine = this._buffer.getLine(row)!
+ const nextLine = this._buffer.getLine(row + 1)!
+
+ if (!nextLine.isWrapped) {
+ rowSeparator = "\r\n"
+ this._lastCursorRow = row + 1
+ this._lastCursorCol = 0
+ } else {
+ rowSeparator = ""
+ const thisRowLastChar = currentLine.getCell(currentLine.length - 1)
+ const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2)
+ const nextRowFirstChar = nextLine.getCell(0)
+
+ if (thisRowLastChar) this._thisRowLastChar = thisRowLastChar
+ if (thisRowLastSecondChar) this._thisRowLastSecondChar = thisRowLastSecondChar
+ if (nextRowFirstChar) this._nextRowFirstChar = nextRowFirstChar
+
+ const isNextRowFirstCharDoubleWidth = this._nextRowFirstChar.getWidth() > 1
+
+ let isValid = false
+
+ if (
+ this._nextRowFirstChar.getChars() &&
+ (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0)
+ ) {
+ if (
+ (this._thisRowLastChar.getChars() || this._thisRowLastChar.getWidth() === 0) &&
+ equalBg(this._thisRowLastChar, this._nextRowFirstChar)
+ ) {
+ isValid = true
+ }
+
+ if (
+ isNextRowFirstCharDoubleWidth &&
+ (this._thisRowLastSecondChar.getChars() || this._thisRowLastSecondChar.getWidth() === 0) &&
+ equalBg(this._thisRowLastChar, this._nextRowFirstChar) &&
+ equalBg(this._thisRowLastSecondChar, this._nextRowFirstChar)
+ ) {
+ isValid = true
+ }
+ }
+
+ if (!isValid) {
+ rowSeparator = "-".repeat(this._nullCellCount + 1)
+ rowSeparator += "\u001b[1D\u001b[1X"
+
+ if (this._nullCellCount > 0) {
+ rowSeparator += "\u001b[A"
+ rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C`
+ rowSeparator += `\u001b[${this._nullCellCount}X`
+ rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}D`
+ rowSeparator += "\u001b[B"
+ }
+
+ this._lastContentCursorRow = row + 1
+ this._lastContentCursorCol = 0
+ this._lastCursorRow = row + 1
+ this._lastCursorCol = 0
+ }
+ }
+ }
+
+ this._allRows[this._rowIndex] = this._currentRow
+ this._allRowSeparators[this._rowIndex++] = rowSeparator
+ this._currentRow = ""
+ this._nullCellCount = 0
+ }
+
+ private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] {
+ const sgrSeq: number[] = []
+ const fgChanged = !equalFg(cell, oldCell)
+ const bgChanged = !equalBg(cell, oldCell)
+ const flagsChanged = !equalFlags(cell, oldCell)
+
+ if (fgChanged || bgChanged || flagsChanged) {
+ if (this._isAttributeDefault(cell)) {
+ if (!this._isAttributeDefault(oldCell)) {
+ sgrSeq.push(0)
+ }
+ } else {
+ if (fgChanged) {
+ const color = cell.getFgColor()
+ const mode = cell.getFgColorMode()
+ if (mode === 2) {
+ // RGB
+ sgrSeq.push(38, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff)
+ } else if (mode === 1) {
+ // Palette
+ if (color >= 16) {
+ sgrSeq.push(38, 5, color)
+ } else {
+ sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7))
+ }
+ } else {
+ sgrSeq.push(39)
+ }
+ }
+ if (bgChanged) {
+ const color = cell.getBgColor()
+ const mode = cell.getBgColorMode()
+ if (mode === 2) {
+ // RGB
+ sgrSeq.push(48, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff)
+ } else if (mode === 1) {
+ // Palette
+ if (color >= 16) {
+ sgrSeq.push(48, 5, color)
+ } else {
+ sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7))
+ }
+ } else {
+ sgrSeq.push(49)
+ }
+ }
+ if (flagsChanged) {
+ if (!!cell.isInverse() !== !!oldCell.isInverse()) {
+ sgrSeq.push(cell.isInverse() ? 7 : 27)
+ }
+ if (!!cell.isBold() !== !!oldCell.isBold()) {
+ sgrSeq.push(cell.isBold() ? 1 : 22)
+ }
+ if (!!cell.isUnderline() !== !!oldCell.isUnderline()) {
+ sgrSeq.push(cell.isUnderline() ? 4 : 24)
+ }
+ if (!!cell.isBlink() !== !!oldCell.isBlink()) {
+ sgrSeq.push(cell.isBlink() ? 5 : 25)
+ }
+ if (!!cell.isInvisible() !== !!oldCell.isInvisible()) {
+ sgrSeq.push(cell.isInvisible() ? 8 : 28)
+ }
+ if (!!cell.isItalic() !== !!oldCell.isItalic()) {
+ sgrSeq.push(cell.isItalic() ? 3 : 23)
+ }
+ if (!!cell.isDim() !== !!oldCell.isDim()) {
+ sgrSeq.push(cell.isDim() ? 2 : 22)
+ }
+ if (!!cell.isStrikethrough() !== !!oldCell.isStrikethrough()) {
+ sgrSeq.push(cell.isStrikethrough() ? 9 : 29)
+ }
+ }
+ }
+ }
+
+ return sgrSeq
+ }
+
+ private _isAttributeDefault(cell: IBufferCell): boolean {
+ return (
+ cell.getFgColorMode() === 0 &&
+ cell.getBgColorMode() === 0 &&
+ !cell.isBold() &&
+ !cell.isItalic() &&
+ !cell.isUnderline() &&
+ !cell.isBlink() &&
+ !cell.isInverse() &&
+ !cell.isInvisible() &&
+ !cell.isDim() &&
+ !cell.isStrikethrough()
+ )
+ }
+
+ protected _nextCell(cell: IBufferCell, _oldCell: IBufferCell, row: number, col: number): void {
+ const isPlaceHolderCell = cell.getWidth() === 0
+
+ if (isPlaceHolderCell) {
+ return
+ }
+
+ const isEmptyCell = cell.getChars() === ""
+
+ const sgrSeq = this._diffStyle(cell, this._cursorStyle)
+
+ const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0
+
+ if (styleChanged) {
+ if (this._nullCellCount > 0) {
+ if (!equalBg(this._cursorStyle, this._backgroundCell)) {
+ this._currentRow += `\u001b[${this._nullCellCount}X`
+ }
+ this._currentRow += `\u001b[${this._nullCellCount}C`
+ this._nullCellCount = 0
+ }
+
+ this._lastContentCursorRow = this._lastCursorRow = row
+ this._lastContentCursorCol = this._lastCursorCol = col
+
+ this._currentRow += `\u001b[${sgrSeq.join(";")}m`
+
+ const line = this._buffer.getLine(row)
+ const cellFromLine = line?.getCell(col)
+ if (cellFromLine) {
+ this._cursorStyle = cellFromLine
+ this._cursorStyleRow = row
+ this._cursorStyleCol = col
+ }
+ }
+
+ if (isEmptyCell) {
+ this._nullCellCount += cell.getWidth()
+ } else {
+ if (this._nullCellCount > 0) {
+ if (equalBg(this._cursorStyle, this._backgroundCell)) {
+ this._currentRow += `\u001b[${this._nullCellCount}C`
+ } else {
+ this._currentRow += `\u001b[${this._nullCellCount}X`
+ this._currentRow += `\u001b[${this._nullCellCount}C`
+ }
+ this._nullCellCount = 0
+ }
+
+ this._currentRow += cell.getChars()
+
+ this._lastContentCursorRow = this._lastCursorRow = row
+ this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth()
+ }
+ }
+
+ protected _serializeString(excludeFinalCursorPosition?: boolean): string {
+ let rowEnd = this._allRows.length
+
+ if (this._buffer.length - this._firstRow <= this._terminal.rows) {
+ rowEnd = this._lastContentCursorRow + 1 - this._firstRow
+ this._lastCursorCol = this._lastContentCursorCol
+ this._lastCursorRow = this._lastContentCursorRow
+ }
+
+ let content = ""
+
+ for (let i = 0; i < rowEnd; i++) {
+ content += this._allRows[i]
+ if (i + 1 < rowEnd) {
+ content += this._allRowSeparators[i]
+ }
+ }
+
+ if (!excludeFinalCursorPosition) {
+ // Get cursor position relative to viewport (1-indexed for ANSI)
+ // cursorY is relative to the viewport, cursorX is column position
+ const cursorRow = this._buffer.cursorY + 1 // 1-indexed
+ const cursorCol = this._buffer.cursorX + 1 // 1-indexed
+
+ // Use absolute cursor positioning (CUP - Cursor Position)
+ // This is more reliable than relative moves which depend on knowing
+ // exactly where the cursor ended up after all the content
+ content += `\u001b[${cursorRow};${cursorCol}H`
+ }
+
+ return content
+ }
+}
+
+// ============================================================================
+// SerializeAddon Class
+// ============================================================================
+
+export class SerializeAddon implements ITerminalAddon {
+ private _terminal?: ITerminalCore
+
+ /**
+ * Activate the addon (called by Terminal.loadAddon)
+ */
+ public activate(terminal: ITerminalCore): void {
+ this._terminal = terminal
+ }
+
+ /**
+ * Dispose the addon and clean up resources
+ */
+ public dispose(): void {
+ this._terminal = undefined
+ }
+
+ /**
+ * Serializes terminal rows into a string that can be written back to the
+ * terminal to restore the state. The cursor will also be positioned to the
+ * correct cell.
+ *
+ * @param options Custom options to allow control over what gets serialized.
+ */
+ public serialize(options?: ISerializeOptions): string {
+ if (!this._terminal) {
+ throw new Error("Cannot use addon until it has been loaded")
+ }
+
+ const terminal = this._terminal as any
+ const buffer = terminal.buffer
+
+ if (!buffer) {
+ return ""
+ }
+
+ const activeBuffer = buffer.active || buffer.normal
+ if (!activeBuffer) {
+ return ""
+ }
+
+ let content = options?.range
+ ? this._serializeBufferByRange(activeBuffer, options.range, true)
+ : this._serializeBufferByScrollback(activeBuffer, options?.scrollback)
+
+ // Handle alternate buffer if active and not excluded
+ if (!options?.excludeAltBuffer) {
+ const altBuffer = buffer.alternate
+ if (altBuffer && buffer.active?.type === "alternate") {
+ const alternateContent = this._serializeBufferByScrollback(altBuffer, undefined)
+ content += `\u001b[?1049h\u001b[H${alternateContent}`
+ }
+ }
+
+ return content
+ }
+
+ /**
+ * Serializes terminal content as plain text (no escape sequences)
+ * @param options Custom options to allow control over what gets serialized.
+ */
+ public serializeAsText(options?: { scrollback?: number; trimWhitespace?: boolean }): string {
+ if (!this._terminal) {
+ throw new Error("Cannot use addon until it has been loaded")
+ }
+
+ const terminal = this._terminal as any
+ const buffer = terminal.buffer
+
+ if (!buffer) {
+ return ""
+ }
+
+ const activeBuffer = buffer.active || buffer.normal
+ if (!activeBuffer) {
+ return ""
+ }
+
+ const maxRows = activeBuffer.length
+ const scrollback = options?.scrollback
+ const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + this._terminal.rows, 0, maxRows)
+
+ const startRow = maxRows - correctRows
+ const endRow = maxRows - 1
+ const lines: string[] = []
+
+ for (let row = startRow; row <= endRow; row++) {
+ const line = activeBuffer.getLine(row)
+ if (line) {
+ const text = line.translateToString(options?.trimWhitespace ?? true)
+ lines.push(text)
+ }
+ }
+
+ // Trim trailing empty lines if requested
+ if (options?.trimWhitespace) {
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
+ lines.pop()
+ }
+ }
+
+ return lines.join("\n")
+ }
+
+ private _serializeBufferByScrollback(buffer: IBuffer, scrollback?: number): string {
+ const maxRows = buffer.length
+ const rows = this._terminal?.rows ?? 24
+ const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + rows, 0, maxRows)
+ return this._serializeBufferByRange(
+ buffer,
+ {
+ start: maxRows - correctRows,
+ end: maxRows - 1,
+ },
+ false,
+ )
+ }
+
+ private _serializeBufferByRange(
+ buffer: IBuffer,
+ range: ISerializeRange,
+ excludeFinalCursorPosition: boolean,
+ ): string {
+ const handler = new StringSerializeHandler(buffer, this._terminal!)
+ const cols = this._terminal?.cols ?? 80
+ return handler.serialize(
+ {
+ start: { x: 0, y: range.start },
+ end: { x: cols, y: range.end },
+ },
+ excludeFinalCursorPosition,
+ )
+ }
+}
diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx
new file mode 100644
index 000000000..49a45a432
--- /dev/null
+++ b/packages/desktop/src/components/terminal.tsx
@@ -0,0 +1,151 @@
+import { init, Terminal as Term, FitAddon } from "ghostty-web"
+import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
+import { createReconnectingWS, ReconnectingWebSocket } from "@solid-primitives/websocket"
+import { useSDK } from "@/context/sdk"
+import { SerializeAddon } from "@/addons/serialize"
+import { LocalPTY } from "@/context/session"
+
+await init()
+
+export interface TerminalProps extends ComponentProps<"div"> {
+ pty: LocalPTY
+ onSubmit?: () => void
+ onCleanup?: (pty: LocalPTY) => void
+}
+
+export const Terminal = (props: TerminalProps) => {
+ const sdk = useSDK()
+ let container!: HTMLDivElement
+ const [local, others] = splitProps(props, ["pty", "class", "classList"])
+ let ws: ReconnectingWebSocket
+ let term: Term
+ let serializeAddon: SerializeAddon
+ let fitAddon: FitAddon
+
+ onMount(async () => {
+ ws = createReconnectingWS(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
+ term = new Term({
+ cursorBlink: true,
+ fontSize: 14,
+ fontFamily: "TX-02, monospace",
+ allowTransparency: true,
+ theme: {
+ background: "#191515",
+ foreground: "#d4d4d4",
+ },
+ scrollback: 10_000,
+ })
+ term.attachCustomKeyEventHandler((event) => {
+ // allow for ctrl-` to toggle terminal in parent
+ if (event.ctrlKey && event.key.toLowerCase() === "`") {
+ event.preventDefault()
+ return true
+ }
+ return false
+ })
+
+ fitAddon = new FitAddon()
+ serializeAddon = new SerializeAddon()
+ term.loadAddon(serializeAddon)
+ term.loadAddon(fitAddon)
+
+ term.open(container)
+
+ if (local.pty.buffer) {
+ const originalSize = { cols: term.cols, rows: term.rows }
+ let resized = false
+ if (local.pty.rows && local.pty.cols) {
+ term.resize(local.pty.cols, local.pty.rows)
+ resized = true
+ }
+ term.write(local.pty.buffer)
+ if (local.pty.scrollY) {
+ term.scrollToLine(local.pty.scrollY)
+ }
+ if (resized) {
+ term.resize(originalSize.cols, originalSize.rows)
+ }
+ }
+
+ container.focus()
+
+ fitAddon.fit()
+ fitAddon.observeResize()
+ window.addEventListener("resize", () => fitAddon.fit())
+ term.onResize(async (size) => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ await sdk.client.pty.update({
+ path: { id: local.pty.id },
+ body: {
+ size: {
+ cols: size.cols,
+ rows: size.rows,
+ },
+ },
+ })
+ }
+ })
+ term.onData((data) => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(data)
+ }
+ })
+ term.onKey((key) => {
+ if (key.key == "Enter") {
+ props.onSubmit?.()
+ }
+ })
+ // term.onScroll((ydisp) => {
+ // console.log("Scroll position:", ydisp)
+ // })
+ ws.addEventListener("open", () => {
+ console.log("WebSocket connected")
+ sdk.client.pty.update({
+ path: { id: local.pty.id },
+ body: {
+ size: {
+ cols: term.cols,
+ rows: term.rows,
+ },
+ },
+ })
+ })
+ ws.addEventListener("message", (event) => {
+ term.write(event.data)
+ })
+ ws.addEventListener("error", (error) => {
+ console.error("WebSocket error:", error)
+ })
+ ws.addEventListener("close", () => {
+ console.log("WebSocket disconnected")
+ })
+ })
+
+ onCleanup(() => {
+ if (serializeAddon && props.onCleanup) {
+ const buffer = serializeAddon.serialize()
+ props.onCleanup({
+ ...local.pty,
+ buffer,
+ rows: term.rows,
+ cols: term.cols,
+ scrollY: term.getViewportY(),
+ })
+ }
+ ws?.close()
+ term?.dispose()
+ })
+
+ return (
+ <div
+ ref={container}
+ data-component="terminal"
+ classList={{
+ ...(local.classList ?? {}),
+ "size-full px-6 py-3 font-mono": true,
+ [local.class ?? ""]: !!local.class,
+ }}
+ {...others}
+ />
+ )
+}
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx
index 81e8b537a..ca736e84e 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/packages/desktop/src/context/layout.tsx
@@ -15,12 +15,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
opened: true,
width: 280,
},
+ terminal: {
+ opened: false,
+ height: 280,
+ },
review: {
state: "pane" as "pane" | "tab",
},
}),
{
- name: "___default-layout",
+ name: "____default-layout",
},
)
@@ -61,6 +65,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("sidebar", "width", width)
},
},
+ terminal: {
+ opened: createMemo(() => store.terminal.opened),
+ open() {
+ setStore("terminal", "opened", true)
+ },
+ close() {
+ setStore("terminal", "opened", false)
+ },
+ toggle() {
+ setStore("terminal", "opened", (x) => !x)
+ },
+ height: createMemo(() => store.terminal.height),
+ resize(height: number) {
+ setStore("terminal", "height", height)
+ },
+ },
review: {
state: createMemo(() => store.review?.state ?? "closed"),
pane() {
diff --git a/packages/desktop/src/context/sdk.tsx b/packages/desktop/src/context/sdk.tsx
index 81b32035a..144202ee2 100644
--- a/packages/desktop/src/context/sdk.tsx
+++ b/packages/desktop/src/context/sdk.tsx
@@ -27,6 +27,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
abort.abort()
})
- return { directory: props.directory, client: sdk, event: emitter }
+ return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
},
})
diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx
index 72098a939..4e9fe71f8 100644
--- a/packages/desktop/src/context/session.tsx
+++ b/packages/desktop/src/context/session.tsx
@@ -8,14 +8,25 @@ import { pipe, sumBy } from "remeda"
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@/utils"
+import { useSDK } from "./sdk"
+
+export type LocalPTY = {
+ id: string
+ title: string
+ rows?: number
+ cols?: number
+ buffer?: string
+ scrollY?: number
+}
export const { use: useSession, provider: SessionProvider } = createSimpleContext({
name: "Session",
init: () => {
+ const sdk = useSDK()
const params = useParams()
const sync = useSync()
const name = createMemo(
- () => `___${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`,
+ () => `______${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`,
)
const [store, setStore] = makePersisted(
@@ -23,16 +34,21 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
messageId?: string
tabs: {
active?: string
- opened: string[]
+ all: string[]
}
prompt: Prompt
cursor?: number
+ terminals: {
+ active?: string
+ all: LocalPTY[]
+ }
}>({
tabs: {
- opened: [],
+ all: [],
},
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
+ terminals: { all: [] },
}),
{
name: name(),
@@ -138,7 +154,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
setStore("tabs", "active", tab)
},
setOpenedTabs(tabs: string[]) {
- setStore("tabs", "opened", tabs)
+ setStore("tabs", "all", tabs)
},
async openTab(tab: string) {
if (tab === "chat") {
@@ -146,8 +162,8 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
return
}
if (tab !== "review") {
- if (!store.tabs.opened.includes(tab)) {
- setStore("tabs", "opened", [...store.tabs.opened, tab])
+ if (!store.tabs.all.includes(tab)) {
+ setStore("tabs", "all", [...store.tabs.all, tab])
}
}
setStore("tabs", "active", tab)
@@ -156,28 +172,88 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
batch(() => {
setStore(
"tabs",
- "opened",
- store.tabs.opened.filter((x) => x !== tab),
+ "all",
+ store.tabs.all.filter((x) => x !== tab),
)
if (store.tabs.active === tab) {
- const index = store.tabs.opened.findIndex((f) => f === tab)
- const previous = store.tabs.opened[Math.max(0, index - 1)]
+ const index = store.tabs.all.findIndex((f) => f === tab)
+ const previous = store.tabs.all[Math.max(0, index - 1)]
setStore("tabs", "active", previous)
}
})
},
moveTab(tab: string, to: number) {
- const index = store.tabs.opened.findIndex((f) => f === tab)
+ const index = store.tabs.all.findIndex((f) => f === tab)
if (index === -1) return
setStore(
"tabs",
- "opened",
+ "all",
produce((opened) => {
opened.splice(to, 0, opened.splice(index, 1)[0])
}),
)
},
},
+ terminal: {
+ all: createMemo(() => Object.values(store.terminals.all)),
+ active: createMemo(() => store.terminals.active),
+ new() {
+ sdk.client.pty.create({ body: { title: `Terminal ${store.terminals.all.length + 1}` } }).then((pty) => {
+ const id = pty.data?.id
+ if (!id) return
+ batch(() => {
+ setStore("terminals", "all", [
+ ...store.terminals.all,
+ {
+ id,
+ title: pty.data?.title ?? "Terminal",
+ // rows: pty.data?.rows ?? 24,
+ // cols: pty.data?.cols ?? 80,
+ // buffer: "",
+ // scrollY: 0,
+ },
+ ])
+ setStore("terminals", "active", id)
+ })
+ })
+ },
+ update(pty: Partial<LocalPTY> & { id: string }) {
+ setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
+ sdk.client.pty.update({
+ path: { id: pty.id },
+ body: { title: pty.title, size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined },
+ })
+ },
+ open(id: string) {
+ setStore("terminals", "active", id)
+ },
+ async close(id: string) {
+ batch(() => {
+ setStore(
+ "terminals",
+ "all",
+ store.terminals.all.filter((x) => x.id !== id),
+ )
+ if (store.terminals.active === id) {
+ const index = store.terminals.all.findIndex((f) => f.id === id)
+ const previous = store.tabs.all[Math.max(0, index - 1)]
+ setStore("terminals", "active", previous)
+ }
+ })
+ await sdk.client.pty.remove({ path: { id } })
+ },
+ move(id: string, to: number) {
+ const index = store.terminals.all.findIndex((f) => f.id === id)
+ if (index === -1) return
+ setStore(
+ "terminals",
+ "all",
+ produce((all) => {
+ all.splice(to, 0, all.splice(index, 1)[0])
+ }),
+ )
+ },
+ },
}
},
})
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index 15180c885..106a2e733 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -1,9 +1,9 @@
import { createMemo, For, ParentProps, Show } from "solid-js"
import { DateTime } from "luxon"
-import { A, useParams } from "@solidjs/router"
+import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
-import { base64Encode } from "@/utils"
+import { base64Decode, base64Encode } from "@/utils"
import { Mark } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -12,11 +12,21 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { getFilename } from "@opencode-ai/util/path"
+import { Select } from "@opencode-ai/ui/select"
+import { Session } from "@opencode-ai/sdk/client"
export default function Layout(props: ParentProps) {
+ const navigate = useNavigate()
const params = useParams()
const globalSync = useGlobalSync()
const layout = useLayout()
+ const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
+ const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? [])
+ const currentSession = createMemo(() => sessions().find((s) => s.id === params.id) ?? sessions().at(0))
+
+ function navigateToSession(session: Session | undefined) {
+ navigate(`/${params.dir}/session/${session?.id}`)
+ }
const handleOpenProject = async () => {
// layout.projects.open(dir.)
@@ -24,7 +34,7 @@ export default function Layout(props: ParentProps) {
return (
<div class="relative h-screen flex flex-col">
- <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base">
+ <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex">
<A
href="/"
classList={{
@@ -36,16 +46,110 @@ export default function Layout(props: ParentProps) {
>
<Mark class="shrink-0" />
</A>
+ <div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
+ <div class="flex items-center gap-3">
+ <div class="flex items-center gap-2">
+ <Select
+ options={layout.projects.list().map((project) => getFilename(project.directory))}
+ current={getFilename(currentDirectory())}
+ class="text-14-regular text-text-base"
+ variant="ghost"
+ />
+ <div class="text-text-weaker">/</div>
+ <Select
+ options={sessions()}
+ current={currentSession()}
+ label={(x) => x.title}
+ value={(x) => x.id}
+ onSelect={navigateToSession}
+ class="text-14-regular text-text-base max-w-3xs"
+ variant="ghost"
+ />
+ </div>
+ <Button as={A} href={`/${params.dir}/session`} icon="plus-small">
+ New session
+ </Button>
+ </div>
+ <div class="flex items-center gap-4">
+ <Tooltip
+ class="shrink-0"
+ value={
+ <div class="flex items-center gap-2">
+ <span>Toggle terminal</span>
+ <span class="text-icon-base text-12-medium">Ctrl `</span>
+ </div>
+ }
+ >
+ <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
+ <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+ <Icon
+ size="small"
+ name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
+ class="group-hover/terminal-toggle:hidden"
+ />
+ <Icon
+ size="small"
+ name="layout-bottom-partial"
+ class="hidden group-hover/terminal-toggle:inline-block"
+ />
+ <Icon
+ size="small"
+ name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
+ class="hidden group-active/terminal-toggle:inline-block"
+ />
+ </div>
+ </Button>
+ </Tooltip>
+ </div>
+ </div>
</header>
<div class="h-[calc(100vh-3rem)] flex">
<div
classList={{
- "@container w-12 pb-5 shrink-0 bg-background-base": true,
+ "relative @container w-12 pb-5 shrink-0 bg-background-base": true,
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
>
+ <Show when={layout.sidebar.opened()}>
+ <div
+ class="absolute inset-y-0 right-0 z-10 w-2 translate-x-1/2 cursor-ew-resize"
+ onMouseDown={(e) => {
+ e.preventDefault()
+ const startX = e.clientX
+ const startWidth = layout.sidebar.width()
+ const maxWidth = window.innerWidth * 0.3
+ const minWidth = 150
+ const collapseThreshold = 80
+ let currentWidth = startWidth
+
+ document.body.style.userSelect = "none"
+ document.body.style.overflow = "hidden"
+
+ const onMouseMove = (moveEvent: MouseEvent) => {
+ const deltaX = moveEvent.clientX - startX
+ currentWidth = startWidth + deltaX
+ const clampedWidth = Math.min(maxWidth, Math.max(minWidth, currentWidth))
+ layout.sidebar.resize(clampedWidth)
+ }
+
+ const onMouseUp = () => {
+ document.body.style.userSelect = ""
+ document.body.style.overflow = ""
+ document.removeEventListener("mousemove", onMouseMove)
+ document.removeEventListener("mouseup", onMouseUp)
+
+ if (currentWidth < collapseThreshold) {
+ layout.sidebar.close()
+ }
+ }
+
+ document.addEventListener("mousemove", onMouseMove)
+ document.addEventListener("mouseup", onMouseUp)
+ }}
+ />
+ </Show>
<div class="grow flex flex-col items-start self-stretch gap-4 p-2 min-h-0">
<Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
<Button
@@ -197,7 +301,7 @@ export default function Layout(props: ParentProps) {
</Tooltip>
</div>
</div>
- <main class="size-full overflow-x-hidden">{props.children}</main>
+ <main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main>
</div>
</div>
)
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index d6ce62b70..773625334 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -1,4 +1,4 @@
-import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js"
+import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect } from "solid-js"
import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input"
@@ -31,6 +31,7 @@ import { useSession } from "@/context/session"
import { useLayout } from "@/context/layout"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Diff } from "@opencode-ai/ui/diff"
+import { Terminal } from "@/components/terminal"
export default function Page() {
const layout = useLayout()
@@ -54,6 +55,14 @@ export default function Page() {
document.removeEventListener("keydown", handleKeyDown)
})
+ createEffect(() => {
+ if (layout.terminal.opened()) {
+ if (session.terminal.all().length === 0) {
+ session.terminal.new()
+ }
+ }
+ })
+
const handleKeyDown = (event: KeyboardEvent) => {
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
event.preventDefault()
@@ -73,6 +82,16 @@ export default function Page() {
document.documentElement.setAttribute("data-theme", nextTheme)
return
}
+ if (event.ctrlKey && event.key.toLowerCase() === "`") {
+ event.preventDefault()
+ layout.terminal.toggle()
+ return
+ }
+
+ // @ts-expect-error
+ if (document.activeElement?.dataset?.component === "terminal") {
+ return
+ }
const focused = document.activeElement === inputRef
if (focused) {
@@ -141,7 +160,7 @@ export default function Page() {
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
- const currentTabs = session.layout.tabs.opened
+ const currentTabs = session.layout.tabs.all
const fromIndex = currentTabs?.indexOf(draggable.id.toString())
const toIndex = currentTabs?.indexOf(droppable.id.toString())
if (fromIndex !== toIndex && toIndex !== undefined) {
@@ -259,317 +278,397 @@ export default function Page() {
const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
return (
- <div class="relative bg-background-base size-full overflow-x-hidden">
- <DragDropProvider
- onDragStart={handleDragStart}
- onDragEnd={handleDragEnd}
- onDragOver={handleDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragYAxis />
- <Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}>
- <div class="sticky top-0 shrink-0 flex">
- <Tabs.List>
- <Tabs.Trigger value="chat">
- <div class="flex gap-x-[17px] items-center">
- <div>Session</div>
- <Tooltip
- value={`${new Intl.NumberFormat("en-US", {
- notation: "compact",
- compactDisplay: "short",
- }).format(session.usage.tokens() ?? 0)} Tokens`}
- class="flex items-center gap-1.5"
+ <div class="relative bg-background-base size-full overflow-x-hidden flex flex-col items-start">
+ <div class="min-h-0 grow w-full">
+ <DragDropProvider
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
+ onDragOver={handleDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragYAxis />
+ <Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}>
+ <div class="sticky top-0 shrink-0 flex">
+ <Tabs.List>
+ <Tabs.Trigger value="chat">
+ <div class="flex gap-x-[17px] items-center">
+ <div>Session</div>
+ <Tooltip
+ value={`${new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ compactDisplay: "short",
+ }).format(session.usage.tokens() ?? 0)} Tokens`}
+ class="flex items-center gap-1.5"
+ >
+ <ProgressCircle percentage={session.usage.context() ?? 0} />
+ <div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div>
+ </Tooltip>
+ </div>
+ </Tabs.Trigger>
+ <Show when={layout.review.state() === "tab" && session.diffs().length}>
+ <Tabs.Trigger
+ value="review"
+ closeButton={
+ <IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
+ }
>
- <ProgressCircle percentage={session.usage.context() ?? 0} />
- <div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div>
- </Tooltip>
- </div>
- </Tabs.Trigger>
- <Show when={layout.review.state() === "tab" && session.diffs().length}>
- <Tabs.Trigger
- value="review"
- closeButton={
- <IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
- }
- >
- <div class="flex items-center gap-3">
- <Show when={session.diffs()}>
- <DiffChanges changes={session.diffs()} variant="bars" />
- </Show>
- <div class="flex items-center gap-1.5">
- <div>Review</div>
- <Show when={session.info()?.summary?.files}>
- <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
- {session.info()?.summary?.files ?? 0}
- </div>
+ <div class="flex items-center gap-3">
+ <Show when={session.diffs()}>
+ <DiffChanges changes={session.diffs()} variant="bars" />
</Show>
+ <div class="flex items-center gap-1.5">
+ <div>Review</div>
+ <Show when={session.info()?.summary?.files}>
+ <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
+ {session.info()?.summary?.files ?? 0}
+ </div>
+ </Show>
+ </div>
</div>
- </div>
- </Tabs.Trigger>
- </Show>
- <SortableProvider ids={session.layout.tabs.opened ?? []}>
- <For each={session.layout.tabs.opened ?? []}>
- {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />}
- </For>
- </SortableProvider>
- <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
- <Tooltip value="Open file" class="flex items-center">
- <IconButton
- icon="plus-small"
- variant="ghost"
- iconSize="large"
- onClick={() => setStore("fileSelectOpen", true)}
- />
- </Tooltip>
- </div>
- </Tabs.List>
- </div>
- <Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
- <div
- classList={{
- "w-full flex-1 min-h-0": true,
- grid: layout.review.state() === "tab",
- flex: layout.review.state() === "pane",
- }}
- >
+ </Tabs.Trigger>
+ </Show>
+ <SortableProvider ids={session.layout.tabs.all ?? []}>
+ <For each={session.layout.tabs.all ?? []}>
+ {(tab) => (
+ <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />
+ )}
+ </For>
+ </SortableProvider>
+ <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
+ <Tooltip value="Open file" class="flex items-center">
+ <IconButton
+ icon="plus-small"
+ variant="ghost"
+ iconSize="large"
+ onClick={() => setStore("fileSelectOpen", true)}
+ />
+ </Tooltip>
+ </div>
+ </Tabs.List>
+ </div>
+ <Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
<div
classList={{
- "relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full": true,
- "max-w-146 mx-auto": !wide(),
+ "w-full flex-1 min-h-0": true,
+ grid: layout.review.state() === "tab",
+ flex: layout.review.state() === "pane",
}}
>
- <Switch>
- <Match when={session.id}>
- <div class="flex items-start justify-start h-full min-h-0">
- <SessionMessageRail
- messages={session.messages.user()}
- current={session.messages.active()}
- onMessageSelect={session.messages.setActive}
- working={session.working()}
- wide={wide()}
- />
- <SessionTurn
- sessionID={session.id!}
- messageID={session.messages.active()?.id!}
- classes={{
- root: "pb-20 flex-1 min-w-0",
- content: "pb-20",
- container:
- "w-full " +
- (wide()
- ? "max-w-146 mx-auto px-6"
- : session.messages.user().length > 1
- ? "pr-6 pl-18"
- : "px-6"),
- }}
- diffComponent={Diff}
- />
- </div>
- </Match>
- <Match when={true}>
- <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-146 mx-auto px-6">
- <div class="text-20-medium text-text-weaker">New session</div>
- <div class="flex justify-center items-center gap-3">
- <Icon name="folder" size="small" />
- <div class="text-12-medium text-text-weak">
- {getDirectory(sync.data.path.directory)}
- <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
- </div>
+ <div
+ classList={{
+ "relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full": true,
+ "max-w-146 mx-auto": !wide(),
+ }}
+ >
+ <Switch>
+ <Match when={session.id}>
+ <div class="flex items-start justify-start h-full min-h-0">
+ <SessionMessageRail
+ messages={session.messages.user()}
+ current={session.messages.active()}
+ onMessageSelect={session.messages.setActive}
+ working={session.working()}
+ wide={wide()}
+ />
+ <SessionTurn
+ sessionID={session.id!}
+ messageID={session.messages.active()?.id!}
+ classes={{
+ root: "pb-20 flex-1 min-w-0",
+ content: "pb-20",
+ container:
+ "w-full " +
+ (wide()
+ ? "max-w-146 mx-auto px-6"
+ : session.messages.user().length > 1
+ ? "pr-6 pl-18"
+ : "px-6"),
+ }}
+ diffComponent={Diff}
+ />
</div>
- <div class="flex justify-center items-center gap-3">
- <Icon name="pencil-line" size="small" />
- <div class="text-12-medium text-text-weak">
- Last modified&nbsp;
- <span class="text-text-strong">
- {DateTime.fromMillis(sync.data.project.time.created).toRelative()}
- </span>
+ </Match>
+ <Match when={true}>
+ <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-146 mx-auto px-6">
+ <div class="text-20-medium text-text-weaker">New session</div>
+ <div class="flex justify-center items-center gap-3">
+ <Icon name="folder" size="small" />
+ <div class="text-12-medium text-text-weak">
+ {getDirectory(sync.data.path.directory)}
+ <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
+ </div>
+ </div>
+ <div class="flex justify-center items-center gap-3">
+ <Icon name="pencil-line" size="small" />
+ <div class="text-12-medium text-text-weak">
+ Last modified&nbsp;
+ <span class="text-text-strong">
+ {DateTime.fromMillis(sync.data.project.time.created).toRelative()}
+ </span>
+ </div>
</div>
</div>
+ </Match>
+ </Switch>
+ <div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
+ <div class="w-full max-w-146 px-6">
+ <PromptInput
+ ref={(el) => {
+ inputRef = el
+ }}
+ />
</div>
- </Match>
- </Switch>
- <div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
- <div class="w-full max-w-146 px-6">
- <PromptInput
- ref={(el) => {
- inputRef = el
+ </div>
+ </div>
+ <Show when={layout.review.state() === "pane" && session.diffs().length}>
+ <div
+ classList={{
+ "relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true,
+ }}
+ >
+ <SessionReview
+ classes={{
+ root: "pb-20",
+ header: "px-6",
+ container: "px-6",
}}
+ diffs={session.diffs()}
+ diffComponent={Diff}
+ actions={
+ <Tooltip value="Open in tab">
+ <IconButton
+ icon="expand"
+ variant="ghost"
+ onClick={() => {
+ layout.review.tab()
+ session.layout.setActiveTab("review")
+ }}
+ />
+ </Tooltip>
+ }
/>
</div>
- </div>
+ </Show>
</div>
- <Show when={layout.review.state() === "pane" && session.diffs().length}>
+ </Tabs.Content>
+ <Show when={layout.review.state() === "tab" && session.diffs().length}>
+ <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
<div
classList={{
- "relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true,
+ "relative pt-3 flex-1 min-h-0 overflow-hidden": true,
}}
>
<SessionReview
classes={{
- root: "pb-20",
+ root: "pb-40",
header: "px-6",
container: "px-6",
}}
diffs={session.diffs()}
diffComponent={Diff}
- actions={
- <Tooltip value="Open in tab">
- <IconButton
- icon="expand"
- variant="ghost"
- onClick={() => {
- layout.review.tab()
- session.layout.setActiveTab("review")
- }}
- />
- </Tooltip>
- }
+ split
/>
</div>
- </Show>
- </div>
- </Tabs.Content>
- <Show when={layout.review.state() === "tab" && session.diffs().length}>
- <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
+ </Tabs.Content>
+ </Show>
+ <For each={session.layout.tabs.all}>
+ {(tab) => {
+ const [file] = createResource(
+ () => tab,
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+ <Tabs.Content value={tab} class="select-text mt-3">
+ <Switch>
+ <Match when={file()}>
+ {(f) => (
+ <Code
+ file={{ name: f().path, contents: f().content?.content ?? "" }}
+ overflow="scroll"
+ class="pb-40"
+ />
+ )}
+ </Match>
+ </Switch>
+ </Tabs.Content>
+ )
+ }}
+ </For>
+ </Tabs>
+ <DragOverlay>
+ <Show when={store.activeDraggable}>
+ {(draggedFile) => {
+ const [file] = createResource(
+ () => draggedFile(),
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+ <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
+ <Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
+ </div>
+ )
+ }}
+ </Show>
+ </DragOverlay>
+ </DragDropProvider>
+ <Show when={session.layout.tabs.active}>
+ <div class="absolute inset-x-0 px-6 max-w-146 flex flex-col justify-center items-center z-50 mx-auto bottom-8">
+ <PromptInput
+ ref={(el) => {
+ inputRef = el
+ }}
+ />
+ </div>
+ </Show>
+ <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
+ {/* <FileTree path="" onFileClick={ handleTabClick} /> */}
+ </div>
+ <div class="hidden shrink-0 w-56 p-2">
+ <Show
+ when={local.file.changes().length}
+ fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
+ >
+ <ul class="">
+ <For each={local.file.changes()}>
+ {(path) => (
+ <li>
+ <button
+ onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
+ class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element"
+ >
+ <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
+ <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
+ <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
+ {getDirectory(path)}
+ </span>
+ </button>
+ </li>
+ )}
+ </For>
+ </ul>
+ </Show>
+ </div>
+ <Show when={store.fileSelectOpen}>
+ <SelectDialog
+ defaultOpen
+ title="Select file"
+ placeholder="Search files"
+ emptyMessage="No files found"
+ items={local.file.searchFiles}
+ key={(x) => x}
+ onOpenChange={(open) => setStore("fileSelectOpen", open)}
+ onSelect={(x) => {
+ if (x) {
+ local.file.open(x)
+ return session.layout.openTab("file://" + x)
+ }
+ return undefined
+ }}
+ >
+ {(i) => (
<div
classList={{
- "relative pt-3 flex-1 min-h-0 overflow-hidden": true,
+ "w-full flex items-center justify-between rounded-md": true,
}}
>
- <SessionReview
- classes={{
- root: "pb-40",
- header: "px-6",
- container: "px-6",
- }}
- diffs={session.diffs()}
- diffComponent={Diff}
- split
- />
- </div>
- </Tabs.Content>
- </Show>
- <For each={session.layout.tabs.opened}>
- {(tab) => {
- const [file] = createResource(
- () => tab,
- async (tab) => {
- if (tab.startsWith("file://")) {
- return local.file.node(tab.replace("file://", ""))
- }
- return undefined
- },
- )
- return (
- <Tabs.Content value={tab} class="select-text mt-3">
- <Switch>
- <Match when={file()}>
- {(f) => (
- <Code
- file={{ name: f().path, contents: f().content?.content ?? "" }}
- overflow="scroll"
- class="pb-40"
- />
- )}
- </Match>
- </Switch>
- </Tabs.Content>
- )
- }}
- </For>
- </Tabs>
- <DragOverlay>
- <Show when={store.activeDraggable}>
- {(draggedFile) => {
- const [file] = createResource(
- () => draggedFile(),
- async (tab) => {
- if (tab.startsWith("file://")) {
- return local.file.node(tab.replace("file://", ""))
- }
- return undefined
- },
- )
- return (
- <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
- <Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
+ <div class="flex items-center gap-x-2 grow min-w-0">
+ <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
+ <div class="flex items-center text-14-regular">
+ <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
+ {getDirectory(i)}
+ </span>
+ <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
+ </div>
</div>
- )
- }}
- </Show>
- </DragOverlay>
- </DragDropProvider>
- <Show when={session.layout.tabs.active}>
- <div class="absolute inset-x-0 px-6 max-w-146 flex flex-col justify-center items-center z-50 mx-auto bottom-8">
- <PromptInput
- ref={(el) => {
- inputRef = el
+ <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
+ </div>
+ )}
+ </SelectDialog>
+ </Show>
+ </div>
+ <Show when={layout.terminal.opened()}>
+ <div
+ class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
+ style={{ height: `${layout.terminal.height()}px` }}
+ >
+ <div
+ class="absolute inset-x-0 top-0 z-10 h-2 -translate-y-1/2 cursor-ns-resize"
+ onMouseDown={(e) => {
+ e.preventDefault()
+ const startY = e.clientY
+ const startHeight = layout.terminal.height()
+ const maxHeight = window.innerHeight * 0.6
+ const minHeight = 100
+ const collapseThreshold = 50
+ let currentHeight = startHeight
+
+ document.body.style.userSelect = "none"
+ document.body.style.overflow = "hidden"
+
+ const onMouseMove = (moveEvent: MouseEvent) => {
+ const deltaY = startY - moveEvent.clientY
+ currentHeight = startHeight + deltaY
+ const clampedHeight = Math.min(maxHeight, Math.max(minHeight, currentHeight))
+ layout.terminal.resize(clampedHeight)
+ }
+
+ const onMouseUp = () => {
+ document.body.style.userSelect = ""
+ document.body.style.overflow = ""
+ document.removeEventListener("mousemove", onMouseMove)
+ document.removeEventListener("mouseup", onMouseUp)
+
+ if (currentHeight < collapseThreshold) {
+ layout.terminal.close()
+ }
+ }
+
+ document.addEventListener("mousemove", onMouseMove)
+ document.addEventListener("mouseup", onMouseUp)
}}
/>
- </div>
- </Show>
- <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
- {/* <FileTree path="" onFileClick={ handleTabClick} /> */}
- </div>
- <div class="hidden shrink-0 w-56 p-2">
- <Show when={local.file.changes().length} fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}>
- <ul class="">
- <For each={local.file.changes()}>
- {(path) => (
- <li>
- <button
- onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
- class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element"
+ <Tabs variant="alt" value={session.terminal.active()} onChange={session.terminal.open}>
+ <Tabs.List class="h-10">
+ <For each={session.terminal.all()}>
+ {(terminal) => (
+ <Tabs.Trigger
+ value={terminal.id}
+ closeButton={
+ session.terminal.all().length > 1 && (
+ <IconButton icon="close" variant="ghost" onClick={() => session.terminal.close(terminal.id)} />
+ )
+ }
>
- <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
- <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
- <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
- {getDirectory(path)}
- </span>
- </button>
- </li>
+ {terminal.title}
+ </Tabs.Trigger>
+ )}
+ </For>
+ <div class="h-full flex items-center justify-center">
+ <Tooltip value="Open file" class="flex items-center">
+ <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} />
+ </Tooltip>
+ </div>
+ </Tabs.List>
+ <For each={session.terminal.all()}>
+ {(terminal) => (
+ <Tabs.Content value={terminal.id}>
+ <Terminal pty={terminal} onCleanup={session.terminal.update} />
+ </Tabs.Content>
)}
</For>
- </ul>
- </Show>
- </div>
- <Show when={store.fileSelectOpen}>
- <SelectDialog
- defaultOpen
- title="Select file"
- placeholder="Search files"
- emptyMessage="No files found"
- items={local.file.searchFiles}
- key={(x) => x}
- onOpenChange={(open) => setStore("fileSelectOpen", open)}
- onSelect={(x) => {
- if (x) {
- local.file.open(x)
- return session.layout.openTab("file://" + x)
- }
- return undefined
- }}
- >
- {(i) => (
- <div
- classList={{
- "w-full flex items-center justify-between rounded-md": true,
- }}
- >
- <div class="flex items-center gap-x-2 grow min-w-0">
- <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
- <div class="flex items-center text-14-regular">
- <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
- {getDirectory(i)}
- </span>
- <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
- </div>
- </div>
- <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
- </div>
- )}
- </SelectDialog>
+ </Tabs>
+ </div>
</Show>
</div>
)