summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2025-12-04 15:57:01 -0600
committerAiden Cline <[email protected]>2025-12-04 15:57:01 -0600
commitf9dcd979364acc5172fd0044c1c8b04dcaec9229 (patch)
tree99e6ada5f45288cc72c558b8ce52e34e8fb0659a /packages/desktop/src
parentd763c11a6d5bc57869f11c87f5a293f61e427e0a (diff)
downloadopencode-f9dcd979364acc5172fd0044c1c8b04dcaec9229.tar.gz
opencode-f9dcd979364acc5172fd0044c1c8b04dcaec9229.zip
Revert "feat(desktop): terminal pane (#5081)"
This reverts commit d763c11a6d5bc57869f11c87f5a293f61e427e0a.
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, 294 insertions, 1393 deletions
diff --git a/packages/desktop/src/addons/serialize.ts b/packages/desktop/src/addons/serialize.ts
deleted file mode 100644
index 03899ff10..000000000
--- a/packages/desktop/src/addons/serialize.ts
+++ /dev/null
@@ -1,649 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 49a45a432..000000000
--- a/packages/desktop/src/components/terminal.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-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 ca736e84e..81e8b537a 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/packages/desktop/src/context/layout.tsx
@@ -15,16 +15,12 @@ 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",
},
)
@@ -65,22 +61,6 @@ 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 144202ee2..81b32035a 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, url: globalSDK.url }
+ return { directory: props.directory, client: sdk, event: emitter }
},
})
diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx
index 4e9fe71f8..72098a939 100644
--- a/packages/desktop/src/context/session.tsx
+++ b/packages/desktop/src/context/session.tsx
@@ -8,25 +8,14 @@ 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(
@@ -34,21 +23,16 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
messageId?: string
tabs: {
active?: string
- all: string[]
+ opened: string[]
}
prompt: Prompt
cursor?: number
- terminals: {
- active?: string
- all: LocalPTY[]
- }
}>({
tabs: {
- all: [],
+ opened: [],
},
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
- terminals: { all: [] },
}),
{
name: name(),
@@ -154,7 +138,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
setStore("tabs", "active", tab)
},
setOpenedTabs(tabs: string[]) {
- setStore("tabs", "all", tabs)
+ setStore("tabs", "opened", tabs)
},
async openTab(tab: string) {
if (tab === "chat") {
@@ -162,8 +146,8 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
return
}
if (tab !== "review") {
- if (!store.tabs.all.includes(tab)) {
- setStore("tabs", "all", [...store.tabs.all, tab])
+ if (!store.tabs.opened.includes(tab)) {
+ setStore("tabs", "opened", [...store.tabs.opened, tab])
}
}
setStore("tabs", "active", tab)
@@ -172,88 +156,28 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
batch(() => {
setStore(
"tabs",
- "all",
- store.tabs.all.filter((x) => x !== tab),
+ "opened",
+ store.tabs.opened.filter((x) => x !== tab),
)
if (store.tabs.active === tab) {
- const index = store.tabs.all.findIndex((f) => f === tab)
- const previous = store.tabs.all[Math.max(0, index - 1)]
+ const index = store.tabs.opened.findIndex((f) => f === tab)
+ const previous = store.tabs.opened[Math.max(0, index - 1)]
setStore("tabs", "active", previous)
}
})
},
moveTab(tab: string, to: number) {
- const index = store.tabs.all.findIndex((f) => f === tab)
+ const index = store.tabs.opened.findIndex((f) => f === tab)
if (index === -1) return
setStore(
"tabs",
- "all",
+ "opened",
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 106a2e733..15180c885 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, useNavigate, useParams } from "@solidjs/router"
+import { A, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
-import { base64Decode, base64Encode } from "@/utils"
+import { 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,21 +12,11 @@ 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.)
@@ -34,7 +24,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 flex">
+ <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base">
<A
href="/"
classList={{
@@ -46,110 +36,16 @@ 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={{
- "relative @container w-12 pb-5 shrink-0 bg-background-base": true,
+ "@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
@@ -301,7 +197,7 @@ export default function Layout(props: ParentProps) {
</Tooltip>
</div>
</div>
- <main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main>
+ <main class="size-full overflow-x-hidden">{props.children}</main>
</div>
</div>
)
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 773625334..d6ce62b70 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, createEffect } from "solid-js"
+import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js"
import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input"
@@ -31,7 +31,6 @@ 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()
@@ -55,14 +54,6 @@ 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()
@@ -82,16 +73,6 @@ 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) {
@@ -160,7 +141,7 @@ export default function Page() {
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
- const currentTabs = session.layout.tabs.all
+ const currentTabs = session.layout.tabs.opened
const fromIndex = currentTabs?.indexOf(draggable.id.toString())
const toIndex = currentTabs?.indexOf(droppable.id.toString())
if (fromIndex !== toIndex && toIndex !== undefined) {
@@ -278,397 +259,317 @@ 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 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} />
- }
+ <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="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>
- </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)}
- />
+ <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.List>
- </div>
- <Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
+ </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>
+ </Show>
+ </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",
+ }}
+ >
<div
classList={{
- "w-full flex-1 min-h-0": true,
- grid: layout.review.state() === "tab",
- flex: layout.review.state() === "pane",
+ "relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full": true,
+ "max-w-146 mx-auto": !wide(),
}}
>
- <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>
- </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>
+ <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 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 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>
- </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>
- </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",
+ </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
}}
- 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>
- </Show>
+ </div>
</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">
+ <Show when={layout.review.state() === "pane" && session.diffs().length}>
<div
classList={{
- "relative pt-3 flex-1 min-h-0 overflow-hidden": true,
+ "relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true,
}}
>
<SessionReview
classes={{
- root: "pb-40",
+ root: "pb-20",
header: "px-6",
container: "px-6",
}}
diffs={session.diffs()}
diffComponent={Diff}
- split
+ actions={
+ <Tooltip value="Open in tab">
+ <IconButton
+ icon="expand"
+ variant="ghost"
+ onClick={() => {
+ layout.review.tab()
+ session.layout.setActiveTab("review")
+ }}
+ />
+ </Tooltip>
+ }
/>
</div>
- </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) => (
+ </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">
<div
classList={{
- "w-full flex items-center justify-between rounded-md": true,
+ "relative pt-3 flex-1 min-h-0 overflow-hidden": 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>
+ <SessionReview
+ classes={{
+ root: "pb-40",
+ header: "px-6",
+ container: "px-6",
+ }}
+ diffs={session.diffs()}
+ diffComponent={Diff}
+ split
+ />
</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)
+ </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>
+ )
+ }}
+ </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
}}
/>
- <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)} />
- )
- }
+ </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"
>
- {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>
+ <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>
- </Tabs>
- </div>
+ </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>
</Show>
</div>
)