Reapply "feat(desktop): terminal pane (#5081)"

This reverts commit f9dcd97936.
This commit is contained in:
Adam
2025-12-04 20:32:08 -06:00
parent d82bd430f6
commit 09f522f0aa
26 changed files with 2299 additions and 402 deletions

View File

@@ -135,11 +135,13 @@
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3", "@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "4.3.3", "@solid-primitives/storage": "4.3.3",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "0.3.0",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "16.2.0", "marked": "16.2.0",
"marked-shiki": "1.2.1", "marked-shiki": "1.2.1",
@@ -246,6 +248,7 @@
"@standard-schema/spec": "1.0.0", "@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"ai": "catalog:", "ai": "catalog:",
"bun-pty": "0.4.2",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",
@@ -457,7 +460,7 @@
"ai": "5.0.97", "ai": "5.0.97",
"diff": "8.0.2", "diff": "8.0.2",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"hono": "4.7.10", "hono": "4.10.7",
"hono-openapi": "1.1.1", "hono-openapi": "1.1.1",
"luxon": "3.6.1", "luxon": "3.6.1",
"remeda": "2.26.0", "remeda": "2.26.0",
@@ -1506,6 +1509,8 @@
"@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="], "@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="],
"@solid-primitives/websocket": ["@solid-primitives/websocket@1.3.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-F06tA2FKa5VsnS4E4WEc3jHpsJfXRlMTGOtolugTzCqV3JmJTyvk9UVg1oz6PgGHKGi1CQ91OP8iW34myyJgaQ=="],
"@solidjs/meta": ["@solidjs/meta@0.29.4", "", { "peerDependencies": { "solid-js": ">=1.8.4" } }, "sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g=="], "@solidjs/meta": ["@solidjs/meta@0.29.4", "", { "peerDependencies": { "solid-js": ">=1.8.4" } }, "sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g=="],
"@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="], "@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="],
@@ -1890,6 +1895,8 @@
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.2", "", {}, "sha512-sHImDz6pJDsHAroYpC9ouKVgOyqZ7FP3N+stX5IdMddHve3rf9LIZBDomQcXrACQ7sQDNuwZQHG8BKR7w8krkQ=="],
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
@@ -2334,6 +2341,8 @@
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
"ghostty-web": ["ghostty-web@0.3.0", "", {}, "sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
"giget": ["giget@1.2.5", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.5.4", "pathe": "^2.0.3", "tar": "^6.2.1" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug=="], "giget": ["giget@1.2.5", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.5.4", "pathe": "^2.0.3", "tar": "^6.2.1" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug=="],
@@ -2428,7 +2437,7 @@
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="], "hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
"hono": ["hono@4.7.10", "", {}, "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ=="], "hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],
"hono-openapi": ["hono-openapi@1.1.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q=="], "hono-openapi": ["hono-openapi@1.1.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q=="],

View File

@@ -1,3 +1,3 @@
{ {
"nodeModules": "sha256-ZGKC7h4ScHDzVYj8qb1lN/weZhyZivPS8kpNAZvgO0I=" "nodeModules": "sha256-Wrfwnmo0lpck2rbt6ttkAuDGvBvqqWJfNA8QDQxoZ6I="
} }

View File

@@ -34,7 +34,7 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"ai": "5.0.97", "ai": "5.0.97",
"hono": "4.7.10", "hono": "4.10.7",
"hono-openapi": "1.1.1", "hono-openapi": "1.1.1",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"luxon": "3.6.1", "luxon": "3.6.1",

View File

@@ -33,11 +33,13 @@
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3", "@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "4.3.3", "@solid-primitives/storage": "4.3.3",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "0.3.0",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "16.2.0", "marked": "16.2.0",
"marked-shiki": "1.2.1", "marked-shiki": "1.2.1",

View File

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

View File

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

View File

@@ -15,12 +15,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
opened: true, opened: true,
width: 280, width: 280,
}, },
terminal: {
opened: false,
height: 280,
},
review: { review: {
state: "pane" as "pane" | "tab", 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) 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: { review: {
state: createMemo(() => store.review?.state ?? "closed"), state: createMemo(() => store.review?.state ?? "closed"),
pane() { pane() {

View File

@@ -27,6 +27,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
abort.abort() abort.abort()
}) })
return { directory: props.directory, client: sdk, event: emitter } return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
}, },
}) })

View File

@@ -8,14 +8,25 @@ import { pipe, sumBy } from "remeda"
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk" import { AssistantMessage, UserMessage } from "@opencode-ai/sdk"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { base64Encode } from "@/utils" 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({ export const { use: useSession, provider: SessionProvider } = createSimpleContext({
name: "Session", name: "Session",
init: () => { init: () => {
const sdk = useSDK()
const params = useParams() const params = useParams()
const sync = useSync() const sync = useSync()
const name = createMemo( 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( const [store, setStore] = makePersisted(
@@ -23,16 +34,21 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
messageId?: string messageId?: string
tabs: { tabs: {
active?: string active?: string
opened: string[] all: string[]
} }
prompt: Prompt prompt: Prompt
cursor?: number cursor?: number
terminals: {
active?: string
all: LocalPTY[]
}
}>({ }>({
tabs: { tabs: {
opened: [], all: [],
}, },
prompt: clonePrompt(DEFAULT_PROMPT), prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined, cursor: undefined,
terminals: { all: [] },
}), }),
{ {
name: name(), name: name(),
@@ -138,7 +154,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
setStore("tabs", "active", tab) setStore("tabs", "active", tab)
}, },
setOpenedTabs(tabs: string[]) { setOpenedTabs(tabs: string[]) {
setStore("tabs", "opened", tabs) setStore("tabs", "all", tabs)
}, },
async openTab(tab: string) { async openTab(tab: string) {
if (tab === "chat") { if (tab === "chat") {
@@ -146,8 +162,8 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
return return
} }
if (tab !== "review") { if (tab !== "review") {
if (!store.tabs.opened.includes(tab)) { if (!store.tabs.all.includes(tab)) {
setStore("tabs", "opened", [...store.tabs.opened, tab]) setStore("tabs", "all", [...store.tabs.all, tab])
} }
} }
setStore("tabs", "active", tab) setStore("tabs", "active", tab)
@@ -156,28 +172,88 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
batch(() => { batch(() => {
setStore( setStore(
"tabs", "tabs",
"opened", "all",
store.tabs.opened.filter((x) => x !== tab), store.tabs.all.filter((x) => x !== tab),
) )
if (store.tabs.active === tab) { if (store.tabs.active === tab) {
const index = store.tabs.opened.findIndex((f) => f === tab) const index = store.tabs.all.findIndex((f) => f === tab)
const previous = store.tabs.opened[Math.max(0, index - 1)] const previous = store.tabs.all[Math.max(0, index - 1)]
setStore("tabs", "active", previous) setStore("tabs", "active", previous)
} }
}) })
}, },
moveTab(tab: string, to: number) { 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 if (index === -1) return
setStore( setStore(
"tabs", "tabs",
"opened", "all",
produce((opened) => { produce((opened) => {
opened.splice(to, 0, opened.splice(index, 1)[0]) 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])
}),
)
},
},
} }
}, },
}) })

View File

@@ -1,9 +1,9 @@
import { createMemo, For, ParentProps, Show } from "solid-js" import { createMemo, For, ParentProps, Show } from "solid-js"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { A, useParams } from "@solidjs/router" import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { base64Encode } from "@/utils" import { base64Decode, base64Encode } from "@/utils"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" 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 { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { getFilename } from "@opencode-ai/util/path" 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) { export default function Layout(props: ParentProps) {
const navigate = useNavigate()
const params = useParams() const params = useParams()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const layout = useLayout() 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 () => { const handleOpenProject = async () => {
// layout.projects.open(dir.) // layout.projects.open(dir.)
@@ -24,7 +34,7 @@ export default function Layout(props: ParentProps) {
return ( return (
<div class="relative h-screen flex flex-col"> <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 <A
href="/" href="/"
classList={{ classList={{
@@ -36,16 +46,110 @@ export default function Layout(props: ParentProps) {
> >
<Mark class="shrink-0" /> <Mark class="shrink-0" />
</A> </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> </header>
<div class="h-[calc(100vh-3rem)] flex"> <div class="h-[calc(100vh-3rem)] flex">
<div <div
classList={{ 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, "flex flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base": true, "border-r border-border-weak-base": true,
}} }}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }} 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"> <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()}> <Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
<Button <Button
@@ -197,7 +301,7 @@ export default function Layout(props: ParentProps) {
</Tooltip> </Tooltip>
</div> </div>
</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>
</div> </div>
) )

View File

@@ -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 { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
@@ -31,6 +31,7 @@ import { useSession } from "@/context/session"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Diff } from "@opencode-ai/ui/diff" import { Diff } from "@opencode-ai/ui/diff"
import { Terminal } from "@/components/terminal"
export default function Page() { export default function Page() {
const layout = useLayout() const layout = useLayout()
@@ -54,6 +55,14 @@ export default function Page() {
document.removeEventListener("keydown", handleKeyDown) document.removeEventListener("keydown", handleKeyDown)
}) })
createEffect(() => {
if (layout.terminal.opened()) {
if (session.terminal.all().length === 0) {
session.terminal.new()
}
}
})
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
event.preventDefault() event.preventDefault()
@@ -73,6 +82,16 @@ export default function Page() {
document.documentElement.setAttribute("data-theme", nextTheme) document.documentElement.setAttribute("data-theme", nextTheme)
return 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 const focused = document.activeElement === inputRef
if (focused) { if (focused) {
@@ -141,7 +160,7 @@ export default function Page() {
const handleDragOver = (event: DragEvent) => { const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event const { draggable, droppable } = event
if (draggable && droppable) { if (draggable && droppable) {
const currentTabs = session.layout.tabs.opened const currentTabs = session.layout.tabs.all
const fromIndex = currentTabs?.indexOf(draggable.id.toString()) const fromIndex = currentTabs?.indexOf(draggable.id.toString())
const toIndex = currentTabs?.indexOf(droppable.id.toString()) const toIndex = currentTabs?.indexOf(droppable.id.toString())
if (fromIndex !== toIndex && toIndex !== undefined) { if (fromIndex !== toIndex && toIndex !== undefined) {
@@ -259,317 +278,397 @@ export default function Page() {
const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length) const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
return ( return (
<div class="relative bg-background-base size-full overflow-x-hidden"> <div class="relative bg-background-base size-full overflow-x-hidden flex flex-col items-start">
<DragDropProvider <div class="min-h-0 grow w-full">
onDragStart={handleDragStart} <DragDropProvider
onDragEnd={handleDragEnd} onDragStart={handleDragStart}
onDragOver={handleDragOver} onDragEnd={handleDragEnd}
collisionDetector={closestCenter} onDragOver={handleDragOver}
> collisionDetector={closestCenter}
<DragDropSensors /> >
<ConstrainDragYAxis /> <DragDropSensors />
<Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}> <ConstrainDragYAxis />
<div class="sticky top-0 shrink-0 flex"> <Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}>
<Tabs.List> <div class="sticky top-0 shrink-0 flex">
<Tabs.Trigger value="chat"> <Tabs.List>
<div class="flex gap-x-[17px] items-center"> <Tabs.Trigger value="chat">
<div>Session</div> <div class="flex gap-x-[17px] items-center">
<Tooltip <div>Session</div>
value={`${new Intl.NumberFormat("en-US", { <Tooltip
notation: "compact", value={`${new Intl.NumberFormat("en-US", {
compactDisplay: "short", notation: "compact",
}).format(session.usage.tokens() ?? 0)} Tokens`} compactDisplay: "short",
class="flex items-center gap-1.5" }).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> <ProgressCircle percentage={session.usage.context() ?? 0} />
</Tooltip> <div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div>
</div> </Tooltip>
</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> </div>
</Tabs.Trigger> </Tabs.Trigger>
</Show> <Show when={layout.review.state() === "tab" && session.diffs().length}>
<SortableProvider ids={session.layout.tabs.opened ?? []}> <Tabs.Trigger
<For each={session.layout.tabs.opened ?? []}> value="review"
{(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />} closeButton={
</For> <IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
</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"> <div class="flex items-center gap-3">
<IconButton <Show when={session.diffs()}>
icon="plus-small" <DiffChanges changes={session.diffs()} variant="bars" />
variant="ghost" </Show>
iconSize="large" <div class="flex items-center gap-1.5">
onClick={() => setStore("fileSelectOpen", true)} <div>Review</div>
/> <Show when={session.info()?.summary?.files}>
</Tooltip> <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
</div> {session.info()?.summary?.files ?? 0}
</Tabs.List> </div>
</div> </Show>
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden"> </div>
<div </div>
classList={{ </Tabs.Trigger>
"w-full flex-1 min-h-0": true, </Show>
grid: layout.review.state() === "tab", <SortableProvider ids={session.layout.tabs.all ?? []}>
flex: layout.review.state() === "pane", <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 <div
classList={{ classList={{
"relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full": true, "w-full flex-1 min-h-0": true,
"max-w-146 mx-auto": !wide(), 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>
<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>
</div>
</div>
<Show when={layout.review.state() === "pane" && session.diffs().length}>
<div <div
classList={{ classList={{
"relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true, "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>
</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>
</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>
</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={{
"relative pt-3 flex-1 min-h-0 overflow-hidden": true,
}} }}
> >
<SessionReview <SessionReview
classes={{ classes={{
root: "pb-20", root: "pb-40",
header: "px-6", header: "px-6",
container: "px-6", container: "px-6",
}} }}
diffs={session.diffs()} diffs={session.diffs()}
diffComponent={Diff} diffComponent={Diff}
actions={ split
<Tooltip value="Open in tab">
<IconButton
icon="expand"
variant="ghost"
onClick={() => {
layout.review.tab()
session.layout.setActiveTab("review")
}}
/>
</Tooltip>
}
/> />
</div> </div>
</Show> </Tabs.Content>
</div> </Show>
</Tabs.Content> <For each={session.layout.tabs.all}>
<Show when={layout.review.state() === "tab" && session.diffs().length}> {(tab) => {
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden"> 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 <div
classList={{ classList={{
"relative pt-3 flex-1 min-h-0 overflow-hidden": true, "w-full flex items-center justify-between rounded-md": true,
}} }}
> >
<SessionReview <div class="flex items-center gap-x-2 grow min-w-0">
classes={{ <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
root: "pb-40", <div class="flex items-center text-14-regular">
header: "px-6", <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
container: "px-6", {getDirectory(i)}
}}
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>
)
}}
</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> </span>
</button> <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
</li> </div>
)} </div>
</For> <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
</ul> </div>
)}
</SelectDialog>
</Show> </Show>
</div> </div>
<Show when={store.fileSelectOpen}> <Show when={layout.terminal.opened()}>
<SelectDialog <div
defaultOpen class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
title="Select file" style={{ height: `${layout.terminal.height()}px` }}
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
<div class="absolute inset-x-0 top-0 z-10 h-2 -translate-y-1/2 cursor-ns-resize"
classList={{ onMouseDown={(e) => {
"w-full flex items-center justify-between rounded-md": true, e.preventDefault()
}} const startY = e.clientY
> const startHeight = layout.terminal.height()
<div class="flex items-center gap-x-2 grow min-w-0"> const maxHeight = window.innerHeight * 0.6
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> const minHeight = 100
<div class="flex items-center text-14-regular"> const collapseThreshold = 50
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> let currentHeight = startHeight
{getDirectory(i)}
</span> document.body.style.userSelect = "none"
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> document.body.style.overflow = "hidden"
</div>
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 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)} />
)
}
>
{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> </div>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div> </Tabs.List>
</div> <For each={session.terminal.all()}>
)} {(terminal) => (
</SelectDialog> <Tabs.Content value={terminal.id}>
<Terminal pty={terminal} onCleanup={session.terminal.update} />
</Tabs.Content>
)}
</For>
</Tabs>
</div>
</Show> </Show>
</div> </div>
) )

View File

@@ -72,6 +72,7 @@
"@standard-schema/spec": "1.0.0", "@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"ai": "catalog:", "ai": "catalog:",
"bun-pty": "0.4.2",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",

View File

@@ -5,6 +5,7 @@ import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc" import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
import type { BunWebSocketData } from "hono/bun"
await Log.init({ await Log.init({
print: process.argv.includes("--print-logs"), print: process.argv.includes("--print-logs"),
@@ -27,7 +28,7 @@ process.on("uncaughtException", (e) => {
}) })
}) })
let server: Bun.Server<undefined> let server: Bun.Server<BunWebSocketData>
export const rpc = { export const rpc = {
async server(input: { port: number; hostname: string }) { async server(input: { port: number; hostname: string }) {
if (server) await server.stop(true) if (server) await server.stop(true)
@@ -53,7 +54,9 @@ export const rpc = {
async shutdown() { async shutdown() {
Log.Default.info("worker shutting down") Log.Default.info("worker shutting down")
await Instance.disposeAll() await Instance.disposeAll()
await server.stop(true) // TODO: this should be awaited, but ws connections are
// causing this to hang, need to revisit this
server.stop(true)
}, },
} }

View File

@@ -8,6 +8,7 @@ export namespace Identifier {
permission: "per", permission: "per",
user: "usr", user: "usr",
part: "prt", part: "prt",
pty: "pty",
} as const } as const
export function schema(prefix: keyof typeof prefixes) { export function schema(prefix: keyof typeof prefixes) {

View File

@@ -0,0 +1,199 @@
import { spawn, type IPty } from "bun-pty"
import z from "zod"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { Bus } from "../bus"
import type { WSContext } from "hono/ws"
import { Instance } from "../project/instance"
import { shell } from "@opencode-ai/util/shell"
export namespace Pty {
const log = Log.create({ service: "pty" })
export const Info = z
.object({
id: Identifier.schema("pty"),
title: z.string(),
command: z.string(),
args: z.array(z.string()),
cwd: z.string(),
status: z.enum(["running", "exited"]),
pid: z.number(),
})
.meta({ ref: "Pty" })
export type Info = z.infer<typeof Info>
export const CreateInput = z.object({
command: z.string().optional(),
args: z.array(z.string()).optional(),
cwd: z.string().optional(),
title: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
})
export type CreateInput = z.infer<typeof CreateInput>
export const UpdateInput = z.object({
title: z.string().optional(),
size: z
.object({
rows: z.number(),
cols: z.number(),
})
.optional(),
})
export type UpdateInput = z.infer<typeof UpdateInput>
export const Event = {
Created: Bus.event("pty.created", z.object({ info: Info })),
Updated: Bus.event("pty.updated", z.object({ info: Info })),
Exited: Bus.event("pty.exited", z.object({ id: Identifier.schema("pty"), exitCode: z.number() })),
Deleted: Bus.event("pty.deleted", z.object({ id: Identifier.schema("pty") })),
}
interface ActiveSession {
info: Info
process: IPty
buffer: string
subscribers: Set<WSContext>
}
const state = Instance.state(
() => new Map<string, ActiveSession>(),
async (sessions) => {
for (const session of sessions.values()) {
try {
session.process.kill()
} catch {}
for (const ws of session.subscribers) {
ws.close()
}
}
sessions.clear()
},
)
export function list() {
return Array.from(state().values()).map((s) => s.info)
}
export function get(id: string) {
return state().get(id)?.info
}
export async function create(input: CreateInput) {
const id = Identifier.create("pty", false)
const command = input.command || shell()
const args = input.args || []
const cwd = input.cwd || Instance.directory
const env = { ...process.env, ...input.env } as Record<string, string>
log.info("creating session", { id, cmd: command, args, cwd })
const ptyProcess = spawn(command, args, {
name: "xterm-256color",
cwd,
env,
})
const info = {
id,
title: input.title || `Terminal ${id.slice(-4)}`,
command,
args,
cwd,
status: "running",
pid: ptyProcess.pid,
} as const
const session: ActiveSession = {
info,
process: ptyProcess,
buffer: "",
subscribers: new Set(),
}
state().set(id, session)
ptyProcess.onData((data) => {
if (session.subscribers.size === 0) {
session.buffer += data
return
}
for (const ws of session.subscribers) {
if (ws.readyState === 1) {
ws.send(data)
}
}
})
ptyProcess.onExit(({ exitCode }) => {
log.info("session exited", { id, exitCode })
session.info.status = "exited"
Bus.publish(Event.Exited, { id, exitCode })
state().delete(id)
})
Bus.publish(Event.Created, { info })
return info
}
export async function update(id: string, input: UpdateInput) {
const session = state().get(id)
if (!session) return
if (input.title) {
session.info.title = input.title
}
if (input.size) {
session.process.resize(input.size.cols, input.size.rows)
}
Bus.publish(Event.Updated, { info: session.info })
return session.info
}
export async function remove(id: string) {
const session = state().get(id)
if (!session) return
log.info("removing session", { id })
try {
session.process.kill()
} catch {}
for (const ws of session.subscribers) {
ws.close()
}
state().delete(id)
Bus.publish(Event.Deleted, { id })
}
export function resize(id: string, cols: number, rows: number) {
const session = state().get(id)
if (session && session.info.status === "running") {
session.process.resize(cols, rows)
}
}
export function write(id: string, data: string) {
const session = state().get(id)
if (session && session.info.status === "running") {
session.process.write(data)
}
}
export function connect(id: string, ws: WSContext) {
const session = state().get(id)
if (!session) {
ws.close()
return
}
log.info("client connected to session", { id })
session.subscribers.add(ws)
if (session.buffer) {
ws.send(session.buffer)
session.buffer = ""
}
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(String(message))
},
onClose: () => {
log.info("client disconnected from session", { id })
session.subscribers.delete(ws)
},
}
}
}

View File

@@ -0,0 +1,36 @@
import { resolver } from "hono-openapi"
import z from "zod"
import { Storage } from "../storage/storage"
export const ERRORS = {
400: {
description: "Bad request",
content: {
"application/json": {
schema: resolver(
z
.object({
data: z.any(),
errors: z.array(z.record(z.string(), z.any())),
success: z.literal(false),
})
.meta({
ref: "BadRequestError",
}),
),
},
},
},
404: {
description: "Not found",
content: {
"application/json": {
schema: resolver(Storage.NotFoundError.Schema),
},
},
},
} as const
export function errors(...codes: number[]) {
return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]))
}

View File

@@ -43,43 +43,13 @@ import { Snapshot } from "@/snapshot"
import { SessionSummary } from "@/session/summary" import { SessionSummary } from "@/session/summary"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { SessionStatus } from "@/session/status" import { SessionStatus } from "@/session/status"
import { upgradeWebSocket, websocket } from "hono/bun"
import { errors } from "./error"
import { Pty } from "@/pty"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
const ERRORS = {
400: {
description: "Bad request",
content: {
"application/json": {
schema: resolver(
z
.object({
data: z.any(),
errors: z.array(z.record(z.string(), z.any())),
success: z.literal(false),
})
.meta({
ref: "BadRequestError",
}),
),
},
},
},
404: {
description: "Not found",
content: {
"application/json": {
schema: resolver(Storage.NotFoundError.Schema),
},
},
},
} as const
function errors(...codes: number[]) {
return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]))
}
export namespace Server { export namespace Server {
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@@ -192,7 +162,167 @@ export namespace Server {
}), }),
) )
.use(validator("query", z.object({ directory: z.string().optional() }))) .use(validator("query", z.object({ directory: z.string().optional() })))
.route("/project", ProjectRoute) .route("/project", ProjectRoute)
.get(
"/pty",
describeRoute({
description: "List all PTY sessions",
operationId: "pty.list",
responses: {
200: {
description: "List of sessions",
content: {
"application/json": {
schema: resolver(Pty.Info.array()),
},
},
},
},
}),
async (c) => {
return c.json(Pty.list())
},
)
.post(
"/pty",
describeRoute({
description: "Create a new PTY session",
operationId: "pty.create",
responses: {
200: {
description: "Created session",
content: {
"application/json": {
schema: resolver(Pty.Info),
},
},
},
...errors(400),
},
}),
validator("json", Pty.CreateInput),
async (c) => {
const info = await Pty.create(c.req.valid("json"))
return c.json(info)
},
)
.put(
"/pty/:id",
describeRoute({
description: "Update PTY session",
operationId: "pty.update",
responses: {
200: {
description: "Updated session",
content: {
"application/json": {
schema: resolver(Pty.Info),
},
},
},
...errors(400),
},
}),
validator("param", z.object({ id: z.string() })),
validator("json", Pty.UpdateInput),
async (c) => {
const info = await Pty.update(c.req.valid("param").id, c.req.valid("json"))
return c.json(info)
},
)
.get(
"/pty/:id",
describeRoute({
description: "Get PTY session info",
operationId: "pty.get",
responses: {
200: {
description: "Session info",
content: {
"application/json": {
schema: resolver(Pty.Info),
},
},
},
...errors(404),
},
}),
validator("param", z.object({ id: z.string() })),
async (c) => {
const info = Pty.get(c.req.valid("param").id)
if (!info) {
throw new Storage.NotFoundError({ message: "Session not found" })
}
return c.json(info)
},
)
.delete(
"/pty/:id",
describeRoute({
description: "Remove a PTY session",
operationId: "pty.remove",
responses: {
200: {
description: "Session removed",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(404),
},
}),
validator("param", z.object({ id: z.string() })),
async (c) => {
await Pty.remove(c.req.valid("param").id)
return c.json(true)
},
)
.get(
"/pty/:id/connect",
describeRoute({
description: "Connect to a PTY session",
operationId: "pty.connect",
responses: {
200: {
description: "Connected session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
404: {
description: "Session not found",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
validator("param", z.object({ id: z.string() })),
upgradeWebSocket((c) => {
const id = c.req.param("id")
let handler: ReturnType<typeof Pty.connect>
return {
onOpen(_event, ws) {
handler = Pty.connect(id, ws)
},
onMessage(event) {
handler?.onMessage(String(event.data))
},
onClose() {
handler?.onClose()
},
}
}),
)
.get( .get(
"/config", "/config",
describeRoute({ describeRoute({
@@ -2083,6 +2213,7 @@ export namespace Server {
hostname: opts.hostname, hostname: opts.hostname,
idleTimeout: 0, idleTimeout: 0,
fetch: App().fetch, fetch: App().fetch,
websocket: websocket,
}) })
return server return server
} }

View File

@@ -8,6 +8,23 @@ import type {
ProjectListResponses, ProjectListResponses,
ProjectCurrentData, ProjectCurrentData,
ProjectCurrentResponses, ProjectCurrentResponses,
PtyListData,
PtyListResponses,
PtyCreateData,
PtyCreateResponses,
PtyCreateErrors,
PtyRemoveData,
PtyRemoveResponses,
PtyRemoveErrors,
PtyGetData,
PtyGetResponses,
PtyGetErrors,
PtyUpdateData,
PtyUpdateResponses,
PtyUpdateErrors,
PtyConnectData,
PtyConnectResponses,
PtyConnectErrors,
ConfigGetData, ConfigGetData,
ConfigGetResponses, ConfigGetResponses,
ConfigUpdateData, ConfigUpdateData,
@@ -231,6 +248,76 @@ class Project extends _HeyApiClient {
} }
} }
class Pty extends _HeyApiClient {
/**
* List all PTY sessions
*/
public list<ThrowOnError extends boolean = false>(options?: Options<PtyListData, ThrowOnError>) {
return (options?.client ?? this._client).get<PtyListResponses, unknown, ThrowOnError>({
url: "/pty",
...options,
})
}
/**
* Create a new PTY session
*/
public create<ThrowOnError extends boolean = false>(options?: Options<PtyCreateData, ThrowOnError>) {
return (options?.client ?? this._client).post<PtyCreateResponses, PtyCreateErrors, ThrowOnError>({
url: "/pty",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
})
}
/**
* Remove a PTY session
*/
public remove<ThrowOnError extends boolean = false>(options: Options<PtyRemoveData, ThrowOnError>) {
return (options.client ?? this._client).delete<PtyRemoveResponses, PtyRemoveErrors, ThrowOnError>({
url: "/pty/{id}",
...options,
})
}
/**
* Get PTY session info
*/
public get<ThrowOnError extends boolean = false>(options: Options<PtyGetData, ThrowOnError>) {
return (options.client ?? this._client).get<PtyGetResponses, PtyGetErrors, ThrowOnError>({
url: "/pty/{id}",
...options,
})
}
/**
* Update PTY session
*/
public update<ThrowOnError extends boolean = false>(options: Options<PtyUpdateData, ThrowOnError>) {
return (options.client ?? this._client).put<PtyUpdateResponses, PtyUpdateErrors, ThrowOnError>({
url: "/pty/{id}",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
})
}
/**
* Connect to a PTY session
*/
public connect<ThrowOnError extends boolean = false>(options: Options<PtyConnectData, ThrowOnError>) {
return (options.client ?? this._client).get<PtyConnectResponses, PtyConnectErrors, ThrowOnError>({
url: "/pty/{id}/connect",
...options,
})
}
}
class Config extends _HeyApiClient { class Config extends _HeyApiClient {
/** /**
* Get config info * Get config info
@@ -1005,6 +1092,7 @@ export class OpencodeClient extends _HeyApiClient {
} }
global = new Global({ client: this._client }) global = new Global({ client: this._client })
project = new Project({ client: this._client }) project = new Project({ client: this._client })
pty = new Pty({ client: this._client })
config = new Config({ client: this._client }) config = new Config({ client: this._client })
tool = new Tool({ client: this._client }) tool = new Tool({ client: this._client })
instance = new Instance({ client: this._client }) instance = new Instance({ client: this._client })

View File

@@ -655,6 +655,45 @@ export type EventTuiToastShow = {
} }
} }
export type Pty = {
id: string
title: string
command: string
args: Array<string>
cwd: string
status: "running" | "exited"
pid: number
}
export type EventPtyCreated = {
type: "pty.created"
properties: {
info: Pty
}
}
export type EventPtyUpdated = {
type: "pty.updated"
properties: {
info: Pty
}
}
export type EventPtyExited = {
type: "pty.exited"
properties: {
id: string
exitCode: number
}
}
export type EventPtyDeleted = {
type: "pty.deleted"
properties: {
id: string
}
}
export type EventServerConnected = { export type EventServerConnected = {
type: "server.connected" type: "server.connected"
properties: { properties: {
@@ -690,6 +729,10 @@ export type Event =
| EventTuiPromptAppend | EventTuiPromptAppend
| EventTuiCommandExecute | EventTuiCommandExecute
| EventTuiToastShow | EventTuiToastShow
| EventPtyCreated
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventServerConnected | EventServerConnected
export type GlobalEvent = { export type GlobalEvent = {
@@ -708,6 +751,21 @@ export type Project = {
} }
} }
export type BadRequestError = {
data: unknown
errors: Array<{
[key: string]: unknown
}>
success: false
}
export type NotFoundError = {
name: "NotFoundError"
data: {
message: string
}
}
/** /**
* Custom keybind configurations * Custom keybind configurations
*/ */
@@ -1266,14 +1324,6 @@ export type Config = {
} }
} }
export type BadRequestError = {
data: unknown
errors: Array<{
[key: string]: unknown
}>
success: false
}
export type ToolIds = Array<string> export type ToolIds = Array<string>
export type ToolListItem = { export type ToolListItem = {
@@ -1295,13 +1345,6 @@ export type VcsInfo = {
branch: string branch: string
} }
export type NotFoundError = {
name: "NotFoundError"
data: {
message: string
}
}
export type TextPartInput = { export type TextPartInput = {
id?: string id?: string
type: "text" type: "text"
@@ -1614,6 +1657,181 @@ export type ProjectCurrentResponses = {
export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses]
export type PtyListData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/pty"
}
export type PtyListResponses = {
/**
* List of sessions
*/
200: Array<Pty>
}
export type PtyListResponse = PtyListResponses[keyof PtyListResponses]
export type PtyCreateData = {
body?: {
command?: string
args?: Array<string>
cwd?: string
title?: string
env?: {
[key: string]: string
}
}
path?: never
query?: {
directory?: string
}
url: "/pty"
}
export type PtyCreateErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type PtyCreateError = PtyCreateErrors[keyof PtyCreateErrors]
export type PtyCreateResponses = {
/**
* Created session
*/
200: Pty
}
export type PtyCreateResponse = PtyCreateResponses[keyof PtyCreateResponses]
export type PtyRemoveData = {
body?: never
path: {
id: string
}
query?: {
directory?: string
}
url: "/pty/{id}"
}
export type PtyRemoveErrors = {
/**
* Not found
*/
404: NotFoundError
}
export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors]
export type PtyRemoveResponses = {
/**
* Session removed
*/
200: boolean
}
export type PtyRemoveResponse = PtyRemoveResponses[keyof PtyRemoveResponses]
export type PtyGetData = {
body?: never
path: {
id: string
}
query?: {
directory?: string
}
url: "/pty/{id}"
}
export type PtyGetErrors = {
/**
* Not found
*/
404: NotFoundError
}
export type PtyGetError = PtyGetErrors[keyof PtyGetErrors]
export type PtyGetResponses = {
/**
* Session info
*/
200: Pty
}
export type PtyGetResponse = PtyGetResponses[keyof PtyGetResponses]
export type PtyUpdateData = {
body?: {
title?: string
size?: {
rows: number
cols: number
}
}
path: {
id: string
}
query?: {
directory?: string
}
url: "/pty/{id}"
}
export type PtyUpdateErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors]
export type PtyUpdateResponses = {
/**
* Updated session
*/
200: Pty
}
export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses]
export type PtyConnectData = {
body?: never
path: {
id: string
}
query?: {
directory?: string
}
url: "/pty/{id}/connect"
}
export type PtyConnectErrors = {
/**
* Session not found
*/
404: boolean
}
export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors]
export type PtyConnectResponses = {
/**
* Connected session
*/
200: boolean
}
export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses]
export type ConfigGetData = { export type ConfigGetData = {
body?: never body?: never
path?: never path?: never

View File

@@ -153,10 +153,10 @@ const newIcons = {
stop: `<rect x="5" y="5" width="10" height="10" fill="currentColor"/>`, stop: `<rect x="5" y="5" width="10" height="10" fill="currentColor"/>`,
enter: `<path d="M5.83333 15.8334L2.5 12.5L5.83333 9.16671M3.33333 12.5H17.9167V4.58337H10" stroke="currentColor" stroke-linecap="square"/>`, enter: `<path d="M5.83333 15.8334L2.5 12.5L5.83333 9.16671M3.33333 12.5H17.9167V4.58337H10" stroke="currentColor" stroke-linecap="square"/>`,
"layout-left": `<path d="M2.91675 2.91699L2.91675 2.41699L2.41675 2.41699L2.41675 2.91699L2.91675 2.91699ZM17.0834 2.91699L17.5834 2.91699L17.5834 2.41699L17.0834 2.41699L17.0834 2.91699ZM17.0834 17.0837L17.0834 17.5837L17.5834 17.5837L17.5834 17.0837L17.0834 17.0837ZM2.91675 17.0837L2.41675 17.0837L2.41675 17.5837L2.91675 17.5837L2.91675 17.0837ZM7.41674 17.0837L7.41674 17.5837L8.41674 17.5837L8.41674 17.0837L7.91674 17.0837L7.41674 17.0837ZM8.41674 2.91699L8.41674 2.41699L7.41674 2.41699L7.41674 2.91699L7.91674 2.91699L8.41674 2.91699ZM2.91675 2.91699L2.91675 3.41699L17.0834 3.41699L17.0834 2.91699L17.0834 2.41699L2.91675 2.41699L2.91675 2.91699ZM17.0834 2.91699L16.5834 2.91699L16.5834 17.0837L17.0834 17.0837L17.5834 17.0837L17.5834 2.91699L17.0834 2.91699ZM17.0834 17.0837L17.0834 16.5837L2.91675 16.5837L2.91675 17.0837L2.91675 17.5837L17.0834 17.5837L17.0834 17.0837ZM2.91675 17.0837L3.41675 17.0837L3.41675 2.91699L2.91675 2.91699L2.41675 2.91699L2.41675 17.0837L2.91675 17.0837ZM7.91674 17.0837L8.41674 17.0837L8.41674 2.91699L7.91674 2.91699L7.41674 2.91699L7.41674 17.0837L7.91674 17.0837Z" fill="currentColor"/>`, "layout-left": `<path d="M2.91675 2.91699L2.91675 2.41699L2.41675 2.41699L2.41675 2.91699L2.91675 2.91699ZM17.0834 2.91699L17.5834 2.91699L17.5834 2.41699L17.0834 2.41699L17.0834 2.91699ZM17.0834 17.0837L17.0834 17.5837L17.5834 17.5837L17.5834 17.0837L17.0834 17.0837ZM2.91675 17.0837L2.41675 17.0837L2.41675 17.5837L2.91675 17.5837L2.91675 17.0837ZM7.41674 17.0837L7.41674 17.5837L8.41674 17.5837L8.41674 17.0837L7.91674 17.0837L7.41674 17.0837ZM8.41674 2.91699L8.41674 2.41699L7.41674 2.41699L7.41674 2.91699L7.91674 2.91699L8.41674 2.91699ZM2.91675 2.91699L2.91675 3.41699L17.0834 3.41699L17.0834 2.91699L17.0834 2.41699L2.91675 2.41699L2.91675 2.91699ZM17.0834 2.91699L16.5834 2.91699L16.5834 17.0837L17.0834 17.0837L17.5834 17.0837L17.5834 2.91699L17.0834 2.91699ZM17.0834 17.0837L17.0834 16.5837L2.91675 16.5837L2.91675 17.0837L2.91675 17.5837L17.0834 17.5837L17.0834 17.0837ZM2.91675 17.0837L3.41675 17.0837L3.41675 2.91699L2.91675 2.91699L2.41675 2.91699L2.41675 17.0837L2.91675 17.0837ZM7.91674 17.0837L8.41674 17.0837L8.41674 2.91699L7.91674 2.91699L7.41674 2.91699L7.41674 17.0837L7.91674 17.0837Z" fill="currentColor"/>`,
"layout-left-partial": `<path d="M2.91732 2.91602L7.91732 2.91602L7.91732 17.0827H2.91732L2.91732 2.91602Z" fill="currentColor" fill-opacity="20%" /><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M2.91732 2.91602L7.91732 2.91602M17.084 2.91602L17.084 17.0827M17.084 2.91602L7.91732 2.91602M17.084 17.0827L2.91732 17.0827M17.084 17.0827L7.91732 17.0827M2.91732 17.0827H7.91732M7.91732 17.0827L7.91732 2.91602" stroke="currentColor" stroke-linecap="square"/>`, "layout-left-partial": `<path d="M2.91732 2.91602L7.91732 2.91602L7.91732 17.0827H2.91732L2.91732 2.91602Z" fill="currentColor" fill-opacity="40%" /><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M2.91732 2.91602L7.91732 2.91602M17.084 2.91602L17.084 17.0827M17.084 2.91602L7.91732 2.91602M17.084 17.0827L2.91732 17.0827M17.084 17.0827L7.91732 17.0827M2.91732 17.0827H7.91732M7.91732 17.0827L7.91732 2.91602" stroke="currentColor" stroke-linecap="square"/>`,
"layout-left-full": `<path d="M2.91732 2.91602L7.91732 2.91602L7.91732 17.0827H2.91732L2.91732 2.91602Z" fill="currentColor"/><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M2.91732 2.91602L7.91732 2.91602M17.084 2.91602L17.084 17.0827M17.084 2.91602L7.91732 2.91602M17.084 17.0827L2.91732 17.0827M17.084 17.0827L7.91732 17.0827M2.91732 17.0827H7.91732M7.91732 17.0827L7.91732 2.91602" stroke="currentColor" stroke-linecap="square"/>`, "layout-left-full": `<path d="M2.91732 2.91602L7.91732 2.91602L7.91732 17.0827H2.91732L2.91732 2.91602Z" fill="currentColor"/><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M2.91732 2.91602L7.91732 2.91602M17.084 2.91602L17.084 17.0827M17.084 2.91602L7.91732 2.91602M17.084 17.0827L2.91732 17.0827M17.084 17.0827L7.91732 17.0827M2.91732 17.0827H7.91732M7.91732 17.0827L7.91732 2.91602" stroke="currentColor" stroke-linecap="square"/>`,
"layout-right": `<path d="M17.0832 2.91699L17.5832 2.91699L17.5832 2.41699L17.0832 2.41699L17.0832 2.91699ZM2.91651 2.91699L2.91651 2.41699L2.41651 2.41699L2.41651 2.91699L2.91651 2.91699ZM2.9165 17.0837L2.4165 17.0837L2.4165 17.5837L2.9165 17.5837L2.9165 17.0837ZM17.0832 17.0837L17.0832 17.5837L17.5832 17.5837L17.5832 17.0837L17.0832 17.0837ZM11.5832 17.0837L11.5832 17.5837L12.5832 17.5837L12.5832 17.0837L12.0832 17.0837L11.5832 17.0837ZM12.5832 2.91699L12.5832 2.41699L11.5832 2.41699L11.5832 2.91699L12.0832 2.91699L12.5832 2.91699ZM17.0832 2.91699L17.0832 2.41699L2.91651 2.41699L2.91651 2.91699L2.91651 3.41699L17.0832 3.41699L17.0832 2.91699ZM2.91651 2.91699L2.41651 2.91699L2.4165 17.0837L2.9165 17.0837L3.4165 17.0837L3.41651 2.91699L2.91651 2.91699ZM2.9165 17.0837L2.9165 17.5837L17.0832 17.5837L17.0832 17.0837L17.0832 16.5837L2.9165 16.5837L2.9165 17.0837ZM17.0832 17.0837L17.5832 17.0837L17.5832 2.91699L17.0832 2.91699L16.5832 2.91699L16.5832 17.0837L17.0832 17.0837ZM12.0832 17.0837L12.5832 17.0837L12.5832 2.91699L12.0832 2.91699L11.5832 2.91699L11.5832 17.0837L12.0832 17.0837Z" fill="currentColor"/>`, "layout-right": `<path d="M17.0832 2.91699L17.5832 2.91699L17.5832 2.41699L17.0832 2.41699L17.0832 2.91699ZM2.91651 2.91699L2.91651 2.41699L2.41651 2.41699L2.41651 2.91699L2.91651 2.91699ZM2.9165 17.0837L2.4165 17.0837L2.4165 17.5837L2.9165 17.5837L2.9165 17.0837ZM17.0832 17.0837L17.0832 17.5837L17.5832 17.5837L17.5832 17.0837L17.0832 17.0837ZM11.5832 17.0837L11.5832 17.5837L12.5832 17.5837L12.5832 17.0837L12.0832 17.0837L11.5832 17.0837ZM12.5832 2.91699L12.5832 2.41699L11.5832 2.41699L11.5832 2.91699L12.0832 2.91699L12.5832 2.91699ZM17.0832 2.91699L17.0832 2.41699L2.91651 2.41699L2.91651 2.91699L2.91651 3.41699L17.0832 3.41699L17.0832 2.91699ZM2.91651 2.91699L2.41651 2.91699L2.4165 17.0837L2.9165 17.0837L3.4165 17.0837L3.41651 2.91699L2.91651 2.91699ZM2.9165 17.0837L2.9165 17.5837L17.0832 17.5837L17.0832 17.0837L17.0832 16.5837L2.9165 16.5837L2.9165 17.0837ZM17.0832 17.0837L17.5832 17.0837L17.5832 2.91699L17.0832 2.91699L16.5832 2.91699L16.5832 17.0837L17.0832 17.0837ZM12.0832 17.0837L12.5832 17.0837L12.5832 2.91699L12.0832 2.91699L11.5832 2.91699L11.5832 17.0837L12.0832 17.0837Z" fill="currentColor"/>`,
"layout-right-partial": `<path d="M12.0827 2.91602L2.91602 2.91602L2.91602 17.0827L12.0827 17.0827L12.0827 2.91602Z" fill="currentColor" fill-opacity="20%" /><path d="M2.91602 2.91602L17.0827 2.91602L17.0827 17.0827L2.91602 17.0827M2.91602 2.91602L2.91602 17.0827M2.91602 2.91602L12.0827 2.91602L12.0827 17.0827L2.91602 17.0827" stroke="currentColor" stroke-linecap="square"/>`, "layout-right-partial": `<path d="M12.0827 2.91602L2.91602 2.91602L2.91602 17.0827L12.0827 17.0827L12.0827 2.91602Z" fill="currentColor" fill-opacity="40%" /><path d="M2.91602 2.91602L17.0827 2.91602L17.0827 17.0827L2.91602 17.0827M2.91602 2.91602L2.91602 17.0827M2.91602 2.91602L12.0827 2.91602L12.0827 17.0827L2.91602 17.0827" stroke="currentColor" stroke-linecap="square"/>`,
"layout-right-full": `<path d="M12.0827 2.91602L2.91602 2.91602L2.91602 17.0827L12.0827 17.0827L12.0827 2.91602Z" fill="currentColor"/><path d="M2.91602 2.91602L17.0827 2.91602L17.0827 17.0827L2.91602 17.0827M2.91602 2.91602L2.91602 17.0827M2.91602 2.91602L12.0827 2.91602L12.0827 17.0827L2.91602 17.0827" stroke="currentColor" stroke-linecap="square"/>`, "layout-right-full": `<path d="M12.0827 2.91602L2.91602 2.91602L2.91602 17.0827L12.0827 17.0827L12.0827 2.91602Z" fill="currentColor"/><path d="M2.91602 2.91602L17.0827 2.91602L17.0827 17.0827L2.91602 17.0827M2.91602 2.91602L2.91602 17.0827M2.91602 2.91602L12.0827 2.91602L12.0827 17.0827L2.91602 17.0827" stroke="currentColor" stroke-linecap="square"/>`,
"speech-bubble": `<path d="M18.3334 10.0003C18.3334 5.57324 15.0927 2.91699 10.0001 2.91699C4.90749 2.91699 1.66675 5.57324 1.66675 10.0003C1.66675 11.1497 2.45578 13.1016 2.5771 13.3949C2.5878 13.4207 2.59839 13.4444 2.60802 13.4706C2.69194 13.6996 3.04282 14.9364 1.66675 16.7684C3.5186 17.6538 5.48526 16.1982 5.48526 16.1982C6.84592 16.9202 8.46491 17.0837 10.0001 17.0837C15.0927 17.0837 18.3334 14.4274 18.3334 10.0003Z" stroke="currentColor" stroke-linecap="square"/>`, "speech-bubble": `<path d="M18.3334 10.0003C18.3334 5.57324 15.0927 2.91699 10.0001 2.91699C4.90749 2.91699 1.66675 5.57324 1.66675 10.0003C1.66675 11.1497 2.45578 13.1016 2.5771 13.3949C2.5878 13.4207 2.59839 13.4444 2.60802 13.4706C2.69194 13.6996 3.04282 14.9364 1.66675 16.7684C3.5186 17.6538 5.48526 16.1982 5.48526 16.1982C6.84592 16.9202 8.46491 17.0837 10.0001 17.0837C15.0927 17.0837 18.3334 14.4274 18.3334 10.0003Z" stroke="currentColor" stroke-linecap="square"/>`,
"align-right": `<path d="M12.292 6.04167L16.2503 9.99998L12.292 13.9583M2.91699 9.99998H15.6253M17.0837 3.75V16.25" stroke="currentColor" stroke-linecap="square"/>`, "align-right": `<path d="M12.292 6.04167L16.2503 9.99998L12.292 13.9583M2.91699 9.99998H15.6253M17.0837 3.75V16.25" stroke="currentColor" stroke-linecap="square"/>`,
@@ -167,6 +167,9 @@ const newIcons = {
"bubble-5": `<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="currentColor" stroke-linecap="square"/>`, "bubble-5": `<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="currentColor" stroke-linecap="square"/>`,
github: `<path d="M10.0001 1.62549C14.6042 1.62549 18.3334 5.35465 18.3334 9.95882C18.333 11.7049 17.785 13.4068 16.7666 14.8251C15.7482 16.2434 14.3107 17.3066 12.6563 17.8651C12.2397 17.9484 12.0834 17.688 12.0834 17.4692C12.0834 17.188 12.0938 16.2922 12.0938 15.1776C12.0938 14.3963 11.8334 13.8963 11.5313 13.6359C13.3855 13.4276 15.3334 12.7192 15.3334 9.52132C15.3334 8.60465 15.0105 7.86507 14.4792 7.28174C14.5626 7.0734 14.8542 6.21924 14.3959 5.0734C14.3959 5.0734 13.698 4.84424 12.1042 5.92757C11.4376 5.74007 10.7292 5.64632 10.0209 5.64632C9.31258 5.64632 8.60425 5.74007 7.93758 5.92757C6.34383 4.85465 5.64592 5.0734 5.64592 5.0734C5.18758 6.21924 5.47925 7.0734 5.56258 7.28174C5.03133 7.86507 4.70842 8.61507 4.70842 9.52132C4.70842 12.7088 6.64592 13.4276 8.50008 13.6359C8.2605 13.8442 8.04175 14.2088 7.96883 14.7505C7.48967 14.9692 6.29175 15.3234 5.54175 14.063C5.3855 13.813 4.91675 13.1984 4.2605 13.2088C3.56258 13.2192 3.97925 13.6047 4.27092 13.7609C4.62508 13.9588 5.03133 14.6984 5.12508 14.938C5.29175 15.4067 5.83342 16.3026 7.92717 15.9172C7.92717 16.6151 7.93758 17.2713 7.93758 17.4692C7.93758 17.688 7.78133 17.938 7.36467 17.8651C5.70491 17.3126 4.26126 16.2515 3.23851 14.8324C2.21576 13.4133 1.66583 11.7081 1.66675 9.95882C1.66675 5.35465 5.39592 1.62549 10.0001 1.62549Z" fill="currentColor"/>`, github: `<path d="M10.0001 1.62549C14.6042 1.62549 18.3334 5.35465 18.3334 9.95882C18.333 11.7049 17.785 13.4068 16.7666 14.8251C15.7482 16.2434 14.3107 17.3066 12.6563 17.8651C12.2397 17.9484 12.0834 17.688 12.0834 17.4692C12.0834 17.188 12.0938 16.2922 12.0938 15.1776C12.0938 14.3963 11.8334 13.8963 11.5313 13.6359C13.3855 13.4276 15.3334 12.7192 15.3334 9.52132C15.3334 8.60465 15.0105 7.86507 14.4792 7.28174C14.5626 7.0734 14.8542 6.21924 14.3959 5.0734C14.3959 5.0734 13.698 4.84424 12.1042 5.92757C11.4376 5.74007 10.7292 5.64632 10.0209 5.64632C9.31258 5.64632 8.60425 5.74007 7.93758 5.92757C6.34383 4.85465 5.64592 5.0734 5.64592 5.0734C5.18758 6.21924 5.47925 7.0734 5.56258 7.28174C5.03133 7.86507 4.70842 8.61507 4.70842 9.52132C4.70842 12.7088 6.64592 13.4276 8.50008 13.6359C8.2605 13.8442 8.04175 14.2088 7.96883 14.7505C7.48967 14.9692 6.29175 15.3234 5.54175 14.063C5.3855 13.813 4.91675 13.1984 4.2605 13.2088C3.56258 13.2192 3.97925 13.6047 4.27092 13.7609C4.62508 13.9588 5.03133 14.6984 5.12508 14.938C5.29175 15.4067 5.83342 16.3026 7.92717 15.9172C7.92717 16.6151 7.93758 17.2713 7.93758 17.4692C7.93758 17.688 7.78133 17.938 7.36467 17.8651C5.70491 17.3126 4.26126 16.2515 3.23851 14.8324C2.21576 13.4133 1.66583 11.7081 1.66675 9.95882C1.66675 5.35465 5.39592 1.62549 10.0001 1.62549Z" fill="currentColor"/>`,
discord: `<path d="M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335C12.2932 3.64011 12.1388 3.95557 12.0013 4.27856C10.6732 4.07738 9.32261 4.07738 7.99451 4.27856C7.85694 3.9556 7.70257 3.64014 7.53203 3.3335C6.28441 3.54735 5.06981 3.92365 3.91889 4.45291C1.63401 7.85128 1.01462 11.1652 1.32431 14.4322C2.6624 15.426 4.16009 16.1819 5.7523 16.6668C6.11082 16.1821 6.42806 15.6678 6.70066 15.1295C6.18289 14.9351 5.68315 14.6953 5.20723 14.4128C5.33249 14.3215 5.45499 14.2274 5.57336 14.136C6.95819 14.7907 8.46965 15.1302 9.99997 15.1302C11.5303 15.1302 13.0418 14.7907 14.4266 14.136C14.5463 14.2343 14.6688 14.3284 14.7927 14.4128C14.3159 14.6957 13.8152 14.9361 13.2965 15.1309C13.5688 15.669 13.8861 16.1828 14.2449 16.6668C15.8385 16.1838 17.3373 15.4283 18.6756 14.4335C19.039 10.645 18.0549 7.36145 16.0742 4.45014ZM7.09294 12.423C6.22992 12.423 5.51693 11.6357 5.51693 10.6671C5.51693 9.69852 6.20514 8.90427 7.09019 8.90427C7.97524 8.90427 8.68272 9.69852 8.66758 10.6671C8.65244 11.6357 7.97248 12.423 7.09294 12.423ZM12.907 12.423C12.0426 12.423 11.3324 11.6357 11.3324 10.6671C11.3324 9.69852 12.0206 8.90427 12.907 8.90427C13.7934 8.90427 14.4954 9.69852 14.4803 10.6671C14.4651 11.6357 13.7865 12.423 12.907 12.423Z" fill="currentColor"/>`, discord: `<path d="M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335C12.2932 3.64011 12.1388 3.95557 12.0013 4.27856C10.6732 4.07738 9.32261 4.07738 7.99451 4.27856C7.85694 3.9556 7.70257 3.64014 7.53203 3.3335C6.28441 3.54735 5.06981 3.92365 3.91889 4.45291C1.63401 7.85128 1.01462 11.1652 1.32431 14.4322C2.6624 15.426 4.16009 16.1819 5.7523 16.6668C6.11082 16.1821 6.42806 15.6678 6.70066 15.1295C6.18289 14.9351 5.68315 14.6953 5.20723 14.4128C5.33249 14.3215 5.45499 14.2274 5.57336 14.136C6.95819 14.7907 8.46965 15.1302 9.99997 15.1302C11.5303 15.1302 13.0418 14.7907 14.4266 14.136C14.5463 14.2343 14.6688 14.3284 14.7927 14.4128C14.3159 14.6957 13.8152 14.9361 13.2965 15.1309C13.5688 15.669 13.8861 16.1828 14.2449 16.6668C15.8385 16.1838 17.3373 15.4283 18.6756 14.4335C19.039 10.645 18.0549 7.36145 16.0742 4.45014ZM7.09294 12.423C6.22992 12.423 5.51693 11.6357 5.51693 10.6671C5.51693 9.69852 6.20514 8.90427 7.09019 8.90427C7.97524 8.90427 8.68272 9.69852 8.66758 10.6671C8.65244 11.6357 7.97248 12.423 7.09294 12.423ZM12.907 12.423C12.0426 12.423 11.3324 11.6357 11.3324 10.6671C11.3324 9.69852 12.0206 8.90427 12.907 8.90427C13.7934 8.90427 14.4954 9.69852 14.4803 10.6671C14.4651 11.6357 13.7865 12.423 12.907 12.423Z" fill="currentColor"/>`,
"layout-bottom": `<path d="M18.125 18.125L1.875 18.125L1.875 1.875L18.125 1.875L18.125 18.125ZM3.125 12.8308L3.125 16.875L16.875 16.875L16.875 12.8308L3.125 12.8308ZM3.125 3.125L3.125 11.5808L16.875 11.5808L16.875 3.125L3.125 3.125Z" fill="currentColor"/>`,
"layout-bottom-partial": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor" fill-opacity="40%" /><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`,
"layout-bottom-full": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor"/><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`,
} }
export interface IconProps extends ComponentProps<"svg"> { export interface IconProps extends ComponentProps<"svg"> {

View File

@@ -20,6 +20,7 @@
[data-component="select-content"] { [data-component="select-content"] {
min-width: 4rem; min-width: 4rem;
max-width: 23rem;
overflow: hidden; overflow: hidden;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border-width: 1px; border-width: 1px;
@@ -39,6 +40,7 @@
} }
[data-slot="select-select-content-list"] { [data-slot="select-select-content-list"] {
min-height: 2rem;
overflow-y: auto; overflow-y: auto;
max-height: 12rem; max-height: 12rem;
white-space: nowrap; white-space: nowrap;

View File

@@ -1,10 +1,10 @@
import { Select as Kobalte } from "@kobalte/core/select" import { Select as Kobalte } from "@kobalte/core/select"
import { createMemo, type ComponentProps } from "solid-js" import { createMemo, splitProps, type ComponentProps } from "solid-js"
import { pipe, groupBy, entries, map } from "remeda" import { pipe, groupBy, entries, map } from "remeda"
import { Button, ButtonProps } from "./button" import { Button, ButtonProps } from "./button"
import { Icon } from "./icon" import { Icon } from "./icon"
export interface SelectProps<T> { export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "onSelect"> & {
placeholder?: string placeholder?: string
options: T[] options: T[]
current?: T current?: T
@@ -17,10 +17,21 @@ export interface SelectProps<T> {
} }
export function Select<T>(props: SelectProps<T> & ButtonProps) { export function Select<T>(props: SelectProps<T> & ButtonProps) {
const [local, others] = splitProps(props, [
"class",
"classList",
"placeholder",
"options",
"current",
"value",
"label",
"groupBy",
"onSelect",
])
const grouped = createMemo(() => { const grouped = createMemo(() => {
const result = pipe( const result = pipe(
props.options, local.options,
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), groupBy((x) => (local.groupBy ? local.groupBy(x) : "")),
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))), // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
entries(), entries(),
map(([k, v]) => ({ category: k, options: v })), map(([k, v]) => ({ category: k, options: v })),
@@ -29,28 +40,30 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
}) })
return ( return (
// @ts-ignore
<Kobalte<T, { category: string; options: T[] }> <Kobalte<T, { category: string; options: T[] }>
{...others}
data-component="select" data-component="select"
value={props.current} value={local.current}
options={grouped()} options={grouped()}
optionValue={(x) => (props.value ? props.value(x) : (x as string))} optionValue={(x) => (local.value ? local.value(x) : (x as string))}
optionTextValue={(x) => (props.label ? props.label(x) : (x as string))} optionTextValue={(x) => (local.label ? local.label(x) : (x as string))}
optionGroupChildren="options" optionGroupChildren="options"
placeholder={props.placeholder} placeholder={local.placeholder}
sectionComponent={(props) => ( sectionComponent={(local) => (
<Kobalte.Section data-slot="select-section">{props.section.rawValue.category}</Kobalte.Section> <Kobalte.Section data-slot="select-section">{local.section.rawValue.category}</Kobalte.Section>
)} )}
itemComponent={(itemProps) => ( itemComponent={(itemProps) => (
<Kobalte.Item <Kobalte.Item
data-slot="select-select-item" data-slot="select-select-item"
classList={{ classList={{
...(props.classList ?? {}), ...(local.classList ?? {}),
[props.class ?? ""]: !!props.class, [local.class ?? ""]: !!local.class,
}} }}
{...itemProps} {...itemProps}
> >
<Kobalte.ItemLabel data-slot="select-select-item-label"> <Kobalte.ItemLabel data-slot="select-select-item-label">
{props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)} {local.label ? local.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
</Kobalte.ItemLabel> </Kobalte.ItemLabel>
<Kobalte.ItemIndicator data-slot="select-select-item-indicator"> <Kobalte.ItemIndicator data-slot="select-select-item-indicator">
<Icon name="check-small" size="small" /> <Icon name="check-small" size="small" />
@@ -58,24 +71,25 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
</Kobalte.Item> </Kobalte.Item>
)} )}
onChange={(v) => { onChange={(v) => {
props.onSelect?.(v ?? undefined) local.onSelect?.(v ?? undefined)
}} }}
> >
<Kobalte.Trigger <Kobalte.Trigger
disabled={props.disabled}
data-slot="select-select-trigger" data-slot="select-select-trigger"
as={Button} as={Button}
size={props.size} size={props.size}
variant={props.variant} variant={props.variant}
classList={{ classList={{
...(props.classList ?? {}), ...(local.classList ?? {}),
[props.class ?? ""]: !!props.class, [local.class ?? ""]: !!local.class,
}} }}
> >
<Kobalte.Value<T> data-slot="select-select-trigger-value"> <Kobalte.Value<T> data-slot="select-select-trigger-value">
{(state) => { {(state) => {
const selected = state.selectedOption() ?? props.current const selected = state.selectedOption() ?? local.current
if (!selected) return props.placeholder || "" if (!selected) return local.placeholder || ""
if (props.label) return props.label(selected) if (local.label) return local.label(selected)
return selected as string return selected as string
}} }}
</Kobalte.Value> </Kobalte.Value>
@@ -86,8 +100,8 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
<Kobalte.Portal> <Kobalte.Portal>
<Kobalte.Content <Kobalte.Content
classList={{ classList={{
...(props.classList ?? {}), ...(local.classList ?? {}),
[props.class ?? ""]: !!props.class, [local.class ?? ""]: !!local.class,
}} }}
data-component="select-content" data-component="select-content"
> >

View File

@@ -6,7 +6,7 @@
background-color: var(--background-stronger); background-color: var(--background-stronger);
overflow: clip; overflow: clip;
[data-slot="tabs-tabs-list"] { [data-slot="tabs-list"] {
height: 48px; height: 48px;
width: 100%; width: 100%;
position: relative; position: relative;
@@ -36,7 +36,7 @@
} }
} }
[data-slot="tabs-tabs-trigger-wrapper"] { [data-slot="tabs-trigger-wrapper"] {
position: relative; position: relative;
height: 100%; height: 100%;
display: flex; display: flex;
@@ -58,14 +58,14 @@
border-right: 1px solid var(--border-weak-base); border-right: 1px solid var(--border-weak-base);
background-color: var(--background-base); background-color: var(--background-base);
[data-slot="tabs-tabs-trigger"] { [data-slot="tabs-trigger"] {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 14px 24px; padding: 14px 24px;
} }
[data-slot="tabs-tabs-trigger-close-button"] { [data-slot="tabs-trigger-close-button"] {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -84,12 +84,12 @@
box-shadow: 0 0 0 2px var(--border-focus); box-shadow: 0 0 0 2px var(--border-focus);
} }
&:has([data-hidden]) { &:has([data-hidden]) {
[data-slot="tabs-tabs-trigger-close-button"] { [data-slot="tabs-trigger-close-button"] {
opacity: 0; opacity: 0;
} }
&:hover { &:hover {
[data-slot="tabs-tabs-trigger-close-button"] { [data-slot="tabs-trigger-close-button"] {
opacity: 1; opacity: 1;
} }
} }
@@ -98,23 +98,23 @@
color: var(--text-strong); color: var(--text-strong);
background-color: transparent; background-color: transparent;
border-bottom-color: transparent; border-bottom-color: transparent;
[data-slot="tabs-tabs-trigger-close-button"] { [data-slot="tabs-trigger-close-button"] {
opacity: 1; opacity: 1;
} }
} }
&:hover:not(:disabled):not([data-selected]) { &:hover:not(:disabled):not([data-selected]) {
color: var(--text-strong); color: var(--text-strong);
} }
&:has([data-slot="tabs-tabs-trigger-close-button"]) { &:has([data-slot="tabs-trigger-close-button"]) {
padding-right: 12px; padding-right: 12px;
[data-slot="tabs-tabs-trigger"] { [data-slot="tabs-trigger"] {
padding-right: 0; padding-right: 0;
} }
} }
} }
[data-slot="tabs-tabs-content"] { [data-slot="tabs-content"] {
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
@@ -129,4 +129,80 @@
outline: none; outline: none;
} }
} }
&[data-variant="alt"] {
[data-slot="tabs-list"] {
padding-left: 24px;
padding-right: 24px;
gap: 12px;
border-bottom: 1px solid var(--border-weak-base);
background-color: transparent;
&::after {
border: none;
background-color: transparent;
}
&:empty::after {
display: none;
}
}
[data-slot="tabs-trigger-wrapper"] {
border: none;
color: var(--text-base);
background-color: transparent;
border-bottom-width: 2px;
border-bottom-style: solid;
border-bottom-color: transparent;
gap: 4px;
/* text-14-regular */
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-x-large); /* 171.429% */
letter-spacing: var(--letter-spacing-normal);
[data-slot="tabs-trigger"] {
height: 100%;
padding: 4px;
background-color: transparent;
border-bottom-width: 2px;
border-bottom-color: transparent;
}
[data-slot="tabs-trigger-close-button"] {
display: flex;
align-items: center;
justify-content: center;
}
[data-component="icon-button"] {
width: 16px;
height: 16px;
margin: 0;
}
&:has([data-selected]) {
color: var(--text-strong);
background-color: transparent;
border-bottom-color: var(--icon-strong-base);
}
&:hover:not(:disabled):not([data-selected]) {
color: var(--text-strong);
}
&:has([data-slot="tabs-trigger-close-button"]) {
padding-right: 0;
[data-slot="tabs-trigger"] {
padding-right: 0;
}
}
}
/* [data-slot="tabs-content"] { */
/* } */
}
} }

View File

@@ -2,7 +2,9 @@ import { Tabs as Kobalte } from "@kobalte/core/tabs"
import { Show, splitProps, type JSX } from "solid-js" import { Show, splitProps, type JSX } from "solid-js"
import type { ComponentProps, ParentProps } from "solid-js" import type { ComponentProps, ParentProps } from "solid-js"
export interface TabsProps extends ComponentProps<typeof Kobalte> {} export interface TabsProps extends ComponentProps<typeof Kobalte> {
variant?: "normal" | "alt"
}
export interface TabsListProps extends ComponentProps<typeof Kobalte.List> {} export interface TabsListProps extends ComponentProps<typeof Kobalte.List> {}
export interface TabsTriggerProps extends ComponentProps<typeof Kobalte.Trigger> { export interface TabsTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {
classes?: { classes?: {
@@ -14,11 +16,12 @@ export interface TabsTriggerProps extends ComponentProps<typeof Kobalte.Trigger>
export interface TabsContentProps extends ComponentProps<typeof Kobalte.Content> {} export interface TabsContentProps extends ComponentProps<typeof Kobalte.Content> {}
function TabsRoot(props: TabsProps) { function TabsRoot(props: TabsProps) {
const [split, rest] = splitProps(props, ["class", "classList"]) const [split, rest] = splitProps(props, ["class", "classList", "variant"])
return ( return (
<Kobalte <Kobalte
{...rest} {...rest}
data-component="tabs" data-component="tabs"
data-variant={split.variant || "normal"}
classList={{ classList={{
...(split.classList ?? {}), ...(split.classList ?? {}),
[split.class ?? ""]: !!split.class, [split.class ?? ""]: !!split.class,
@@ -32,7 +35,7 @@ function TabsList(props: TabsListProps) {
return ( return (
<Kobalte.List <Kobalte.List
{...rest} {...rest}
data-slot="tabs-tabs-list" data-slot="tabs-list"
classList={{ classList={{
...(split.classList ?? {}), ...(split.classList ?? {}),
[split.class ?? ""]: !!split.class, [split.class ?? ""]: !!split.class,
@@ -52,7 +55,7 @@ function TabsTrigger(props: ParentProps<TabsTriggerProps>) {
]) ])
return ( return (
<div <div
data-slot="tabs-tabs-trigger-wrapper" data-slot="tabs-trigger-wrapper"
classList={{ classList={{
...(split.classList ?? {}), ...(split.classList ?? {}),
[split.class ?? ""]: !!split.class, [split.class ?? ""]: !!split.class,
@@ -60,14 +63,14 @@ function TabsTrigger(props: ParentProps<TabsTriggerProps>) {
> >
<Kobalte.Trigger <Kobalte.Trigger
{...rest} {...rest}
data-slot="tabs-tabs-trigger" data-slot="tabs-trigger"
classList={{ "group/tab": true, [split.classes?.button ?? ""]: split.classes?.button }} classList={{ "group/tab": true, [split.classes?.button ?? ""]: split.classes?.button }}
> >
{split.children} {split.children}
</Kobalte.Trigger> </Kobalte.Trigger>
<Show when={split.closeButton}> <Show when={split.closeButton}>
{(closeButton) => ( {(closeButton) => (
<div data-slot="tabs-tabs-trigger-close-button" data-hidden={split.hideCloseButton}> <div data-slot="tabs-trigger-close-button" data-hidden={split.hideCloseButton}>
{closeButton()} {closeButton()}
</div> </div>
)} )}
@@ -81,7 +84,7 @@ function TabsContent(props: ParentProps<TabsContentProps>) {
return ( return (
<Kobalte.Content <Kobalte.Content
{...rest} {...rest}
data-slot="tabs-tabs-content" data-slot="tabs-content"
classList={{ classList={{
...(split.classList ?? {}), ...(split.classList ?? {}),
[split.class ?? ""]: !!split.class, [split.class ?? ""]: !!split.class,

View File

@@ -1,7 +1,6 @@
/* [data-component="tooltip-trigger"] { */ [data-component="tooltip-trigger"] {
/* display: flex; */ display: flex;
/* align-items: center; */ }
/* } */
[data-component="tooltip"] { [data-component="tooltip"] {
z-index: 1000; z-index: 1000;

View File

@@ -0,0 +1,13 @@
export function shell() {
const s = process.env.SHELL
if (s) return s
if (process.platform === "darwin") {
return "/bin/zsh"
}
if (process.platform === "win32") {
return process.env.COMSPEC || "cmd.exe"
}
const bash = Bun.which("bash")
if (bash) return bash
return "bash"
}