feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture
Forked from OpenCode v1.17.4 with multi-agent system: - 5 agents: aircoding, scheduler, worker, architect, reviewer - Deterministic DAG scheduling engine (coordinator_tick) - Tool whitelists as hard enforcement - AirCoding validation plugin - V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md - Design documents in docs/
This commit is contained in:
436
packages/tui/src/component/bg-pulse-render.ts
Normal file
436
packages/tui/src/component/bg-pulse-render.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { OptimizedBuffer, RGBA, TextAttributes } from "@opentui/core"
|
||||
import { go } from "../logo"
|
||||
|
||||
const PERIOD = 4600
|
||||
const RINGS = 3
|
||||
const WIDTH = 3.8
|
||||
const TAIL = 9.5
|
||||
const AMP = 0.55
|
||||
const TAIL_AMP = 0.16
|
||||
const BREATH_AMP = 0.05
|
||||
const BREATH_SPEED = 0.0008
|
||||
// Offset so the bg ring emits from the estimated GO center when the logo shimmer peaks.
|
||||
const PHASE_OFFSET = 0.29
|
||||
const LOGO_GAP = 1
|
||||
const LOGO_TOP_BIAS = -1
|
||||
const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0
|
||||
const LOGO_LINES = go.left.map((line, index) => line + " ".repeat(LOGO_GAP) + go.right[index])
|
||||
const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0
|
||||
const LOGO_HEIGHT = LOGO_LINES.length
|
||||
const SPACE = " ".codePointAt(0)!
|
||||
const TOP_HALF = "▀".codePointAt(0)!
|
||||
const FULL_BLOCK = "█".codePointAt(0)!
|
||||
const RING_SCALE = 1 / RINGS
|
||||
const TAIL_SCALE = 1 / TAIL
|
||||
const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3
|
||||
|
||||
const enum LogoCellKind {
|
||||
Background,
|
||||
Top,
|
||||
ShadowTop,
|
||||
Solid,
|
||||
Char,
|
||||
}
|
||||
|
||||
type LogoTemplateCell = {
|
||||
x: number
|
||||
y: number
|
||||
kind: LogoCellKind
|
||||
charCode: number
|
||||
attributes: number
|
||||
topDist: number
|
||||
bottomDist: number
|
||||
}
|
||||
|
||||
const LOGO_TEMPLATE: LogoTemplateCell[] = LOGO_LINES.flatMap((line, y) =>
|
||||
Array.from(line)
|
||||
.map((char, x) => {
|
||||
if (char === " ") return
|
||||
const kind =
|
||||
char === "_"
|
||||
? LogoCellKind.Background
|
||||
: char === "^"
|
||||
? LogoCellKind.Top
|
||||
: char === "~"
|
||||
? LogoCellKind.ShadowTop
|
||||
: char === "█"
|
||||
? LogoCellKind.Solid
|
||||
: LogoCellKind.Char
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
kind,
|
||||
charCode: char.codePointAt(0) ?? SPACE,
|
||||
attributes: x > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0,
|
||||
topDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 - LOGO_HEIGHT),
|
||||
bottomDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 + 1 - LOGO_HEIGHT),
|
||||
}
|
||||
})
|
||||
.filter((cell): cell is LogoTemplateCell => !!cell),
|
||||
)
|
||||
|
||||
export type Rgb = [number, number, number]
|
||||
|
||||
export type GoUpsellArtRenderOptions = {
|
||||
deltaTime?: number
|
||||
rgb?: boolean
|
||||
cache?: boolean
|
||||
}
|
||||
|
||||
const CACHE_FRAME_COUNT = Math.round(PERIOD / (1000 / 30))
|
||||
const CACHE_FRAMES_PER_RENDER = 1
|
||||
|
||||
export function toRgb(color: RGBA): Rgb {
|
||||
const [r, g, b] = color.toInts()
|
||||
return [r, g, b]
|
||||
}
|
||||
|
||||
function clamp(n: number) {
|
||||
return Math.max(0, Math.min(1, n))
|
||||
}
|
||||
|
||||
function writeRgb(buffer: Uint16Array, offset: number, r: number, g: number, b: number, a = 255) {
|
||||
buffer[offset] = r
|
||||
buffer[offset + 1] = g
|
||||
buffer[offset + 2] = b
|
||||
buffer[offset + 3] = a
|
||||
}
|
||||
|
||||
function mixChannel(base: number, overlay: number, alpha: number) {
|
||||
return Math.round(base + (overlay - base) * clamp(alpha))
|
||||
}
|
||||
|
||||
function writeLogoTint(
|
||||
buffer: Uint16Array,
|
||||
offset: number,
|
||||
base: Rgb,
|
||||
primary: Rgb,
|
||||
primaryMix: number,
|
||||
peakMix: number,
|
||||
) {
|
||||
const p = clamp(primaryMix)
|
||||
const q = clamp(peakMix)
|
||||
const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q)
|
||||
const g = mixChannel(mixChannel(base[1], primary[1], p), 255, q)
|
||||
const b = mixChannel(mixChannel(base[2], primary[2], p), 255, q)
|
||||
writeRgb(buffer, offset, r, g, b)
|
||||
}
|
||||
|
||||
function sameRgb(a: Rgb, b: Rgb) {
|
||||
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]
|
||||
}
|
||||
|
||||
export class GoUpsellArtPainter {
|
||||
private panelRgb: Rgb = [0, 0, 0]
|
||||
private primaryRgb: Rgb = [255, 255, 255]
|
||||
private logoBaseRgb: Rgb = [180, 180, 180]
|
||||
private elapsed = 0
|
||||
private distances = new Float32Array(0)
|
||||
private edgeFalloff = new Float32Array(0)
|
||||
private geometryWidth = 0
|
||||
private geometryHeight = 0
|
||||
private reach = 1
|
||||
private logoX = 0
|
||||
private logoY = 0
|
||||
private logoIndexes = new Int32Array(0)
|
||||
private logoRgb: boolean | undefined
|
||||
private pulsePeak = 0
|
||||
private pulsePrimary = 0
|
||||
private cacheDirty = true
|
||||
private frameCache: Array<{ fg: Uint16Array; bg: Uint16Array }> = []
|
||||
private cacheBuildIndex = 0
|
||||
|
||||
setBackgroundPanel(value: RGBA | Rgb | undefined) {
|
||||
if (!value) return false
|
||||
const next = value instanceof RGBA ? toRgb(value) : value
|
||||
if (sameRgb(this.panelRgb, next)) return false
|
||||
this.panelRgb = next
|
||||
this.invalidateCache()
|
||||
return true
|
||||
}
|
||||
|
||||
setLogoBase(value: RGBA | Rgb | undefined) {
|
||||
if (!value) return false
|
||||
const next = value instanceof RGBA ? toRgb(value) : value
|
||||
if (sameRgb(this.logoBaseRgb, next)) return false
|
||||
this.logoBaseRgb = next
|
||||
this.invalidateCache()
|
||||
return true
|
||||
}
|
||||
|
||||
setPrimary(value: RGBA | Rgb | undefined) {
|
||||
if (!value) return false
|
||||
const next = value instanceof RGBA ? toRgb(value) : value
|
||||
if (sameRgb(this.primaryRgb, next)) return false
|
||||
this.primaryRgb = next
|
||||
this.invalidateCache()
|
||||
return true
|
||||
}
|
||||
|
||||
render(frameBuffer: OptimizedBuffer, options: GoUpsellArtRenderOptions = {}) {
|
||||
const rgb = options.rgb === true
|
||||
this.elapsed = (this.elapsed + (options.deltaTime ?? 0)) % PERIOD
|
||||
this.rebuildGeometry(frameBuffer, rgb)
|
||||
if (options.cache !== false) {
|
||||
this.drawCached(frameBuffer, rgb)
|
||||
return
|
||||
}
|
||||
this.drawBackground(frameBuffer, this.elapsed)
|
||||
this.drawLogo(frameBuffer, this.elapsed, rgb)
|
||||
}
|
||||
|
||||
private invalidateCache() {
|
||||
this.cacheDirty = true
|
||||
this.cacheBuildIndex = 0
|
||||
this.frameCache = []
|
||||
}
|
||||
|
||||
private rebuildGeometry(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
const width = frameBuffer.width
|
||||
const height = frameBuffer.height
|
||||
const geometryChanged = width !== this.geometryWidth || height !== this.geometryHeight
|
||||
const logoTemplateChanged = this.logoRgb !== rgb
|
||||
if (!geometryChanged && !logoTemplateChanged) return
|
||||
|
||||
if (geometryChanged) {
|
||||
this.geometryWidth = width
|
||||
this.geometryHeight = height
|
||||
this.logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2))
|
||||
this.logoY = Math.max(
|
||||
0,
|
||||
Math.min(Math.max(0, height - LOGO_HEIGHT), Math.round((height - LOGO_HEIGHT) / 2) + LOGO_TOP_BIAS),
|
||||
)
|
||||
|
||||
const centerX = this.logoX + LOGO_WIDTH / 2
|
||||
const centerY = this.logoY + LOGO_HEIGHT / 2
|
||||
this.reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL
|
||||
this.distances = new Float32Array(width * height)
|
||||
this.edgeFalloff = new Float32Array(width * height)
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const index = y * width + x
|
||||
const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2)
|
||||
this.distances[index] = dist
|
||||
this.edgeFalloff[index] = Math.max(0, 1 - (dist / (this.reach * 0.85)) ** 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logoRgb = rgb
|
||||
this.invalidateCache()
|
||||
this.rebuildCellTemplate(frameBuffer, rgb)
|
||||
}
|
||||
|
||||
private drawCached(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
if (this.cacheDirty) this.startFrameCache(frameBuffer, rgb)
|
||||
if (this.cacheBuildIndex < CACHE_FRAME_COUNT) {
|
||||
this.buildFrameCache(frameBuffer, rgb)
|
||||
this.drawBackground(frameBuffer, this.elapsed)
|
||||
this.drawLogo(frameBuffer, this.elapsed, rgb)
|
||||
return
|
||||
}
|
||||
|
||||
const frame = this.frameCache[Math.floor((this.elapsed / PERIOD) * CACHE_FRAME_COUNT) % CACHE_FRAME_COUNT]
|
||||
if (frame) {
|
||||
frameBuffer.buffers.fg.set(frame.fg)
|
||||
frameBuffer.buffers.bg.set(frame.bg)
|
||||
}
|
||||
}
|
||||
|
||||
private startFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
this.frameCache = []
|
||||
this.cacheBuildIndex = 0
|
||||
this.rebuildCellTemplate(frameBuffer, rgb)
|
||||
this.cacheDirty = false
|
||||
}
|
||||
|
||||
private buildFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
const end = Math.min(CACHE_FRAME_COUNT, this.cacheBuildIndex + CACHE_FRAMES_PER_RENDER)
|
||||
for (; this.cacheBuildIndex < end; this.cacheBuildIndex++) {
|
||||
const t = (this.cacheBuildIndex / CACHE_FRAME_COUNT) * PERIOD
|
||||
this.drawBackground(frameBuffer, t)
|
||||
this.drawLogo(frameBuffer, t, rgb)
|
||||
this.frameCache.push({
|
||||
fg: new Uint16Array(frameBuffer.buffers.fg),
|
||||
bg: new Uint16Array(frameBuffer.buffers.bg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildCellTemplate(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
const buffers = frameBuffer.buffers
|
||||
buffers.char.fill(SPACE)
|
||||
buffers.attributes.fill(0)
|
||||
|
||||
if (this.geometryWidth < LOGO_WIDTH || this.geometryHeight < LOGO_HEIGHT) {
|
||||
this.logoIndexes = new Int32Array(0)
|
||||
return
|
||||
}
|
||||
|
||||
this.logoIndexes = new Int32Array(LOGO_TEMPLATE.length)
|
||||
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
|
||||
const cell = LOGO_TEMPLATE[i]!
|
||||
const index = (this.logoY + cell.y) * this.geometryWidth + this.logoX + cell.x
|
||||
this.logoIndexes[i] = index
|
||||
buffers.attributes[index] = cell.attributes
|
||||
buffers.char[index] =
|
||||
cell.kind === LogoCellKind.Background
|
||||
? SPACE
|
||||
: cell.kind === LogoCellKind.Top || cell.kind === LogoCellKind.ShadowTop
|
||||
? TOP_HALF
|
||||
: cell.kind === LogoCellKind.Solid
|
||||
? rgb
|
||||
? TOP_HALF
|
||||
: FULL_BLOCK
|
||||
: cell.charCode
|
||||
}
|
||||
}
|
||||
|
||||
private drawBackground(frameBuffer: OptimizedBuffer, t: number) {
|
||||
const buffers = frameBuffer.buffers
|
||||
const fg = buffers.fg
|
||||
const bg = buffers.bg
|
||||
const distances = this.distances
|
||||
const edgeFalloff = this.edgeFalloff
|
||||
const baseR = this.panelRgb[0]
|
||||
const baseG = this.panelRgb[1]
|
||||
const baseB = this.panelRgb[2]
|
||||
const deltaR = this.primaryRgb[0] - baseR
|
||||
const deltaG = this.primaryRgb[1] - baseG
|
||||
const deltaB = this.primaryRgb[2] - baseB
|
||||
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
|
||||
|
||||
const phase0 = (t / PERIOD - PHASE_OFFSET + 1) % 1
|
||||
const phase1 = (t / PERIOD + 1 / RINGS - PHASE_OFFSET + 1) % 1
|
||||
const phase2 = (t / PERIOD + 2 / RINGS - PHASE_OFFSET + 1) % 1
|
||||
const envelope0 = Math.sin(phase0 * Math.PI)
|
||||
const envelope1 = Math.sin(phase1 * Math.PI)
|
||||
const envelope2 = Math.sin(phase2 * Math.PI)
|
||||
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
|
||||
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
|
||||
const eased2 = envelope2 * envelope2 * (3 - 2 * envelope2)
|
||||
const head0 = phase0 * this.reach
|
||||
const head1 = phase1 * this.reach
|
||||
const head2 = phase2 * this.reach
|
||||
|
||||
for (let index = 0; index < distances.length; index++) {
|
||||
const dist = distances[index]
|
||||
const delta0 = dist - head0
|
||||
const abs0 = delta0 < 0 ? -delta0 : delta0
|
||||
const crest0 = abs0 < WIDTH ? 0.5 + 0.5 * Math.cos((delta0 / WIDTH) * Math.PI) : 0
|
||||
const tail0 = delta0 < 0 && delta0 > -TAIL ? (1 + delta0 * TAIL_SCALE) ** 2.3 : 0
|
||||
|
||||
const delta1 = dist - head1
|
||||
const abs1 = delta1 < 0 ? -delta1 : delta1
|
||||
const crest1 = abs1 < WIDTH ? 0.5 + 0.5 * Math.cos((delta1 / WIDTH) * Math.PI) : 0
|
||||
const tail1 = delta1 < 0 && delta1 > -TAIL ? (1 + delta1 * TAIL_SCALE) ** 2.3 : 0
|
||||
|
||||
const delta2 = dist - head2
|
||||
const abs2 = delta2 < 0 ? -delta2 : delta2
|
||||
const crest2 = abs2 < WIDTH ? 0.5 + 0.5 * Math.cos((delta2 / WIDTH) * Math.PI) : 0
|
||||
const tail2 = delta2 < 0 && delta2 > -TAIL ? (1 + delta2 * TAIL_SCALE) ** 2.3 : 0
|
||||
|
||||
const level =
|
||||
(crest0 * AMP + tail0 * TAIL_AMP) * eased0 +
|
||||
(crest1 * AMP + tail1 * TAIL_AMP) * eased1 +
|
||||
(crest2 * AMP + tail2 * TAIL_AMP) * eased2
|
||||
const rawStrength = (level * RING_SCALE + breath) * edgeFalloff[index]
|
||||
const strength = (rawStrength > 1 ? 1 : rawStrength) * 0.7
|
||||
const offset = index * 4
|
||||
const r = Math.round(baseR + deltaR * strength)
|
||||
const g = Math.round(baseG + deltaG * strength)
|
||||
const b = Math.round(baseB + deltaB * strength)
|
||||
bg[offset] = fg[offset] = r
|
||||
bg[offset + 1] = fg[offset + 1] = g
|
||||
bg[offset + 2] = fg[offset + 2] = b
|
||||
bg[offset + 3] = fg[offset + 3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
private setLogoPulse(dist: number, head0: number, eased0: number, head1: number, eased1: number) {
|
||||
let peak = 0.04
|
||||
let primary = 0
|
||||
|
||||
const delta0 = dist - head0
|
||||
const core0 = Math.exp(-(Math.abs(delta0 / 1.2) ** 1.8))
|
||||
const soft0 = Math.exp(-(Math.abs(delta0 / 7) ** 1.6))
|
||||
const tail0 = delta0 < 0 && delta0 > -7 ? (1 + delta0 / 7) ** 2.6 : 0
|
||||
peak += core0 * 0.65 * eased0
|
||||
primary += (soft0 * 0.16 + tail0 * 0.22) * eased0
|
||||
|
||||
const delta1 = dist - head1
|
||||
const core1 = Math.exp(-(Math.abs(delta1 / 1.2) ** 1.8))
|
||||
const soft1 = Math.exp(-(Math.abs(delta1 / 7) ** 1.6))
|
||||
const tail1 = delta1 < 0 && delta1 > -7 ? (1 + delta1 / 7) ** 2.6 : 0
|
||||
peak += core1 * 0.65 * eased1
|
||||
primary += (soft1 * 0.16 + tail1 * 0.22) * eased1
|
||||
|
||||
this.pulsePeak = peak > 1 ? 1 : peak
|
||||
this.pulsePrimary = primary > 1 ? 1 : primary
|
||||
}
|
||||
|
||||
private drawLogo(frameBuffer: OptimizedBuffer, t: number, rgb: boolean) {
|
||||
if (this.logoIndexes.length === 0) return
|
||||
|
||||
const buffers = frameBuffer.buffers
|
||||
const fg = buffers.fg
|
||||
const bg = buffers.bg
|
||||
const shadow: Rgb = [
|
||||
mixChannel(this.panelRgb[0], this.logoBaseRgb[0], 0.25),
|
||||
mixChannel(this.panelRgb[1], this.logoBaseRgb[1], 0.25),
|
||||
mixChannel(this.panelRgb[2], this.logoBaseRgb[2], 0.25),
|
||||
]
|
||||
const phase0 = (t / PERIOD) % 1
|
||||
const phase1 = (t / PERIOD + 0.5) % 1
|
||||
const envelope0 = Math.sin(phase0 * Math.PI)
|
||||
const envelope1 = Math.sin(phase1 * Math.PI)
|
||||
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
|
||||
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
|
||||
const head0 = phase0 * LOGO_REACH
|
||||
const head1 = phase1 * LOGO_REACH
|
||||
|
||||
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
|
||||
const cell = LOGO_TEMPLATE[i]!
|
||||
const index = this.logoIndexes[i]!
|
||||
const offset = index * 4
|
||||
this.setLogoPulse(cell.topDist, head0, eased0, head1, eased1)
|
||||
const topPeak = this.pulsePeak
|
||||
const topPrimary = this.pulsePrimary
|
||||
this.setLogoPulse(cell.bottomDist, head0, eased0, head1, eased1)
|
||||
const bottomPeak = this.pulsePeak
|
||||
const bottomPrimary = this.pulsePrimary
|
||||
|
||||
if (cell.kind === LogoCellKind.Background) {
|
||||
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, Math.max(topPeak, bottomPeak) * 0.18)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.kind === LogoCellKind.Top) {
|
||||
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
|
||||
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, bottomPeak * 0.18)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.kind === LogoCellKind.ShadowTop) {
|
||||
writeLogoTint(fg, offset, shadow, this.primaryRgb, 0, topPeak * 0.18)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.kind === LogoCellKind.Solid && rgb) {
|
||||
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
|
||||
writeLogoTint(bg, offset, this.logoBaseRgb, this.primaryRgb, bottomPrimary, bottomPeak)
|
||||
continue
|
||||
}
|
||||
|
||||
writeLogoTint(
|
||||
fg,
|
||||
offset,
|
||||
this.logoBaseRgb,
|
||||
this.primaryRgb,
|
||||
(topPrimary + bottomPrimary) / 2,
|
||||
(topPeak + bottomPeak) / 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
99
packages/tui/src/component/bg-pulse.tsx
Normal file
99
packages/tui/src/component/bg-pulse.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
FrameBufferRenderable,
|
||||
RGBA,
|
||||
type OptimizedBuffer,
|
||||
type RenderContext,
|
||||
type RenderableOptions,
|
||||
} from "@opentui/core"
|
||||
import { extend, useRenderer } from "@opentui/solid"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { tint, useTheme } from "../context/theme"
|
||||
import { GoUpsellArtPainter } from "./bg-pulse-render"
|
||||
|
||||
type GoUpsellArtOptions = RenderableOptions<FrameBufferRenderable> & {
|
||||
backgroundPanel?: RGBA
|
||||
primary?: RGBA
|
||||
logoBase?: RGBA
|
||||
}
|
||||
|
||||
class GoUpsellArtRenderable extends FrameBufferRenderable {
|
||||
private painter = new GoUpsellArtPainter()
|
||||
|
||||
constructor(ctx: RenderContext, options: GoUpsellArtOptions = {}) {
|
||||
const width = typeof options.width === "number" ? options.width : 1
|
||||
const height = typeof options.height === "number" ? options.height : 1
|
||||
super(ctx, {
|
||||
...options,
|
||||
width,
|
||||
height,
|
||||
live: options.live ?? true,
|
||||
respectAlpha: false,
|
||||
})
|
||||
|
||||
if (options.width !== undefined && typeof options.width !== "number") this.width = options.width
|
||||
if (options.height !== undefined && typeof options.height !== "number") this.height = options.height
|
||||
this.painter.setBackgroundPanel(options.backgroundPanel)
|
||||
this.painter.setPrimary(options.primary)
|
||||
this.painter.setLogoBase(options.logoBase)
|
||||
}
|
||||
|
||||
set backgroundPanel(value: RGBA | undefined) {
|
||||
if (this.painter.setBackgroundPanel(value)) this.requestRender()
|
||||
}
|
||||
|
||||
set logoBase(value: RGBA | undefined) {
|
||||
if (this.painter.setLogoBase(value)) this.requestRender()
|
||||
}
|
||||
|
||||
set primary(value: RGBA | undefined) {
|
||||
if (this.painter.setPrimary(value)) this.requestRender()
|
||||
}
|
||||
|
||||
protected override renderSelf(buffer: OptimizedBuffer, deltaTime = 0): void {
|
||||
if (!this.visible || this.isDestroyed) return
|
||||
|
||||
this.painter.render(this.frameBuffer, {
|
||||
deltaTime,
|
||||
rgb: this._ctx.capabilities?.rgb === true,
|
||||
})
|
||||
super.renderSelf(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
go_upsell_art: typeof GoUpsellArtRenderable
|
||||
}
|
||||
}
|
||||
|
||||
extend({ go_upsell_art: GoUpsellArtRenderable })
|
||||
|
||||
export function BgPulse() {
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
let targetFps = renderer.targetFps
|
||||
let maxFps = renderer.maxFps
|
||||
|
||||
onMount(() => {
|
||||
targetFps = renderer.targetFps
|
||||
maxFps = renderer.maxFps
|
||||
renderer.targetFps = 30
|
||||
renderer.maxFps = 30
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.targetFps = targetFps
|
||||
renderer.maxFps = maxFps
|
||||
})
|
||||
|
||||
return (
|
||||
<go_upsell_art
|
||||
width="100%"
|
||||
height="100%"
|
||||
backgroundPanel={theme.backgroundPanel}
|
||||
primary={theme.primary}
|
||||
logoBase={tint(theme.background, theme.text, 0.62)}
|
||||
live
|
||||
/>
|
||||
)
|
||||
}
|
||||
79
packages/tui/src/component/command-palette.tsx
Normal file
79
packages/tui/src/component/command-palette.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
|
||||
import { type DialogContext } from "../ui/dialog"
|
||||
import {
|
||||
COMMAND_PALETTE_COMMAND,
|
||||
formatKeyBindings,
|
||||
type OpenTuiKeymap,
|
||||
useKeymapSelector,
|
||||
useOpencodeKeymap,
|
||||
} from "../keymap"
|
||||
import { useTuiConfig } from "../config"
|
||||
|
||||
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
|
||||
|
||||
function isVisiblePaletteCommand(command: PaletteCommandEntry["command"]) {
|
||||
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
|
||||
}
|
||||
|
||||
function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
|
||||
const suggested = entry.command.suggested
|
||||
if (typeof suggested === "boolean") return suggested
|
||||
if (typeof suggested === "function") return suggested() === true
|
||||
return false
|
||||
}
|
||||
|
||||
export function CommandPaletteDialog() {
|
||||
const config = useTuiConfig()
|
||||
const keymap = useOpencodeKeymap()
|
||||
const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
|
||||
const query = {
|
||||
namespace: "palette",
|
||||
}
|
||||
const reachable = keymap.getCommandEntries({
|
||||
...query,
|
||||
visibility: "reachable",
|
||||
filter: isVisiblePaletteCommand,
|
||||
})
|
||||
const registeredBindings = keymap.getCommandBindings({
|
||||
visibility: "registered",
|
||||
commands: reachable.map((entry) => entry.command.name),
|
||||
})
|
||||
|
||||
return reachable.map((entry) => ({
|
||||
...entry,
|
||||
bindings: registeredBindings.get(entry.command.name) ?? entry.bindings,
|
||||
}))
|
||||
})
|
||||
const options = createMemo(() =>
|
||||
entries().map((entry) => ({
|
||||
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name,
|
||||
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
|
||||
category: typeof entry.command.category === "string" ? entry.command.category : undefined,
|
||||
footer: formatKeyBindings(entry.bindings, config),
|
||||
value: entry.command.name,
|
||||
suggested: isSuggestedPaletteCommand(entry),
|
||||
onSelect: (dialog: DialogContext) => {
|
||||
dialog.clear()
|
||||
keymap.dispatchCommand(entry.command.name)
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
let ref: DialogSelectRef<string>
|
||||
const list = () => {
|
||||
if (ref?.filter) return options()
|
||||
return [
|
||||
...options()
|
||||
.filter((option) => option.suggested)
|
||||
.map((option) => ({
|
||||
...option,
|
||||
value: `suggested:${option.value}`,
|
||||
category: "Suggested",
|
||||
})),
|
||||
...options(),
|
||||
]
|
||||
}
|
||||
|
||||
return <DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} />
|
||||
}
|
||||
31
packages/tui/src/component/dialog-agent.tsx
Normal file
31
packages/tui/src/component/dialog-agent.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useLocal } from "../context/local"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
|
||||
export function DialogAgent() {
|
||||
const local = useLocal()
|
||||
const dialog = useDialog()
|
||||
|
||||
const options = createMemo(() =>
|
||||
local.agent.list().map((item) => {
|
||||
return {
|
||||
value: item.name,
|
||||
title: item.name,
|
||||
description: item.native ? "native" : item.description,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Select agent"
|
||||
current={local.agent.current()?.name}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
local.agent.set(option.value)
|
||||
dialog.clear()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
103
packages/tui/src/component/dialog-console-org.tsx
Normal file
103
packages/tui/src/component/dialog-console-org.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { createResource, createMemo } from "solid-js"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useTheme } from "../context/theme"
|
||||
import type { ExperimentalConsoleListOrgsResponse } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number]
|
||||
|
||||
const accountHost = (url: string) => {
|
||||
try {
|
||||
return new URL(url).host
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
const accountLabel = (item: Pick<OrgOption, "accountEmail" | "accountUrl">) =>
|
||||
`${item.accountEmail} ${accountHost(item.accountUrl)}`
|
||||
|
||||
export function DialogConsoleOrg() {
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const [orgs] = createResource(async () => {
|
||||
const result = await sdk.client.experimental.console.listOrgs({}, { throwOnError: true })
|
||||
return result.data?.orgs ?? []
|
||||
})
|
||||
|
||||
const current = createMemo(() => orgs()?.find((item) => item.active))
|
||||
|
||||
const options = createMemo(() => {
|
||||
const listed = orgs()
|
||||
if (listed === undefined) {
|
||||
return [
|
||||
{
|
||||
title: "Loading orgs...",
|
||||
value: "loading",
|
||||
onSelect: () => {},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (listed.length === 0) {
|
||||
return [
|
||||
{
|
||||
title: "No orgs found",
|
||||
value: "empty",
|
||||
onSelect: () => {},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return listed
|
||||
.toSorted((a, b) => {
|
||||
const activeAccountA = a.active ? 0 : 1
|
||||
const activeAccountB = b.active ? 0 : 1
|
||||
if (activeAccountA !== activeAccountB) return activeAccountA - activeAccountB
|
||||
|
||||
const accountCompare = accountLabel(a).localeCompare(accountLabel(b))
|
||||
if (accountCompare !== 0) return accountCompare
|
||||
|
||||
return a.orgName.localeCompare(b.orgName)
|
||||
})
|
||||
.map((item) => ({
|
||||
title: item.orgName,
|
||||
value: item,
|
||||
category: accountLabel(item),
|
||||
categoryView: (
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.accent}>{item.accountEmail}</text>
|
||||
<text fg={theme.textMuted}>{accountHost(item.accountUrl)}</text>
|
||||
</box>
|
||||
),
|
||||
onSelect: async () => {
|
||||
if (item.active) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
|
||||
await sdk.client.experimental.console.switchOrg(
|
||||
{
|
||||
accountID: item.accountID,
|
||||
orgID: item.orgID,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
|
||||
await sdk.client.instance.dispose()
|
||||
toast.show({
|
||||
message: `Switched to ${item.orgName}`,
|
||||
variant: "info",
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
return <DialogSelect<string | OrgOption> title="Switch org" options={options()} current={current()} />
|
||||
}
|
||||
85
packages/tui/src/component/dialog-mcp.tsx
Normal file
85
packages/tui/src/component/dialog-mcp.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useLocal } from "../context/local"
|
||||
import { useSync } from "../context/sync"
|
||||
import { map, pipe, entries, sortBy } from "remeda"
|
||||
import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useSDK } from "../context/sdk"
|
||||
|
||||
function Status(props: { enabled: boolean; loading: boolean }) {
|
||||
const { theme } = useTheme()
|
||||
if (props.loading) {
|
||||
return <span style={{ fg: theme.textMuted }}>⋯ Loading</span>
|
||||
}
|
||||
if (props.enabled) {
|
||||
return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
|
||||
}
|
||||
return <span style={{ fg: theme.textMuted }}>○ Disabled</span>
|
||||
}
|
||||
|
||||
export function DialogMcp() {
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const [, setRef] = createSignal<DialogSelectRef<unknown>>()
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
|
||||
const options = createMemo(() => {
|
||||
// Track sync data and loading state to trigger re-render when they change
|
||||
const mcpData = sync.data.mcp
|
||||
const loadingMcp = loading()
|
||||
|
||||
return pipe(
|
||||
mcpData ?? {},
|
||||
entries(),
|
||||
sortBy(([name]) => name),
|
||||
map(([name, status]) => ({
|
||||
value: name,
|
||||
title: name,
|
||||
description: status.status === "failed" ? "failed" : status.status,
|
||||
footer: <Status enabled={local.mcp.isEnabled(name)} loading={loadingMcp === name} />,
|
||||
category: undefined,
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
const actions = createMemo(() => [
|
||||
{
|
||||
command: "dialog.mcp.toggle",
|
||||
title: "toggle",
|
||||
onTrigger: async (option: DialogSelectOption<string>) => {
|
||||
// Prevent toggling while an operation is already in progress
|
||||
if (loading() !== null) return
|
||||
|
||||
setLoading(option.value)
|
||||
try {
|
||||
await local.mcp.toggle(option.value)
|
||||
// Refresh MCP status from server
|
||||
const status = await sdk.client.mcp.status()
|
||||
if (status.data) {
|
||||
sync.set("mcp", status.data)
|
||||
} else {
|
||||
console.error("Failed to refresh MCP status: no data returned")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle MCP:", error)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
ref={setRef}
|
||||
title="MCPs"
|
||||
options={options()}
|
||||
actions={actions()}
|
||||
onSelect={(_option) => {
|
||||
// Don't close on select, only on escape
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
193
packages/tui/src/component/dialog-model.tsx
Normal file
193
packages/tui/src/component/dialog-model.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useLocal } from "../context/local"
|
||||
import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { DialogVariant } from "./dialog-variant"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
import { useConnected } from "./use-connected"
|
||||
import { useSync } from "../context/sync"
|
||||
|
||||
export function DialogModel(props: { providerID?: string }) {
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const [query, setQuery] = createSignal("")
|
||||
|
||||
const connected = useConnected()
|
||||
const providers = createDialogProviderOptions()
|
||||
|
||||
const showExtra = createMemo(() => connected() && !props.providerID)
|
||||
|
||||
const options = createMemo(() => {
|
||||
const needle = query().trim()
|
||||
const showSections = showExtra() && needle.length === 0
|
||||
const favorites = connected() ? local.model.favorite() : []
|
||||
const recents = local.model.recent()
|
||||
|
||||
function toOptions(items: typeof favorites, category: string) {
|
||||
if (!showSections) return []
|
||||
return items.flatMap((item) => {
|
||||
const provider = sync.data.provider.find((provider) => provider.id === item.providerID)
|
||||
if (!provider) return []
|
||||
const model = provider.models[item.modelID]
|
||||
if (!model) return []
|
||||
return [
|
||||
{
|
||||
key: item,
|
||||
value: { providerID: provider.id, modelID: model.id },
|
||||
title: model.name ?? item.modelID,
|
||||
description: provider.name,
|
||||
category,
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
onSelect(provider.id, model.id)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const favoriteOptions = toOptions(favorites, "Favorites")
|
||||
const recentOptions = toOptions(
|
||||
recents.filter(
|
||||
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
|
||||
),
|
||||
"Recent",
|
||||
)
|
||||
|
||||
const providerOptions = pipe(
|
||||
sync.data.provider,
|
||||
sortBy(
|
||||
(provider) => provider.id !== "opencode",
|
||||
(provider) => provider.name,
|
||||
),
|
||||
flatMap((provider) =>
|
||||
pipe(
|
||||
provider.models,
|
||||
entries(),
|
||||
filter(([_, info]) => info.status !== "deprecated"),
|
||||
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
|
||||
map(([model, info]) => ({
|
||||
value: { providerID: provider.id, modelID: model },
|
||||
title: info.name ?? model,
|
||||
releaseDate: info.release_date,
|
||||
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
|
||||
? "(Favorite)"
|
||||
: undefined,
|
||||
category: connected() ? provider.name : undefined,
|
||||
disabled: provider.id === "opencode" && model.includes("-nano"),
|
||||
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect() {
|
||||
onSelect(provider.id, model)
|
||||
},
|
||||
})),
|
||||
filter((option) => {
|
||||
if (!showSections) return true
|
||||
if (
|
||||
favorites.some(
|
||||
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
|
||||
)
|
||||
)
|
||||
return false
|
||||
if (
|
||||
recents.some(
|
||||
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
|
||||
)
|
||||
)
|
||||
return false
|
||||
return true
|
||||
}),
|
||||
(options) => sortModelOptions(options, props.providerID !== undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const popularProviders = !connected()
|
||||
? pipe(
|
||||
providers(),
|
||||
map((option) => ({
|
||||
...option,
|
||||
category: "Popular providers",
|
||||
})),
|
||||
take(6),
|
||||
)
|
||||
: []
|
||||
|
||||
if (needle) {
|
||||
return [
|
||||
...fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
|
||||
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
|
||||
]
|
||||
}
|
||||
|
||||
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
|
||||
})
|
||||
|
||||
const provider = createMemo(() =>
|
||||
props.providerID ? sync.data.provider.find((item) => item.id === props.providerID) : null,
|
||||
)
|
||||
|
||||
const title = createMemo(() => {
|
||||
const value = provider()
|
||||
if (!value) return "Select model"
|
||||
return value.name
|
||||
})
|
||||
|
||||
function onSelect(providerID: string, modelID: string) {
|
||||
local.model.set({ providerID, modelID }, { recent: true })
|
||||
const list = local.model.variant.list()
|
||||
const cur = local.model.variant.selected()
|
||||
if (cur === "default" || (cur && list.includes(cur))) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
if (list.length > 0) {
|
||||
dialog.replace(() => <DialogVariant />)
|
||||
return
|
||||
}
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect<ReturnType<typeof options>[number]["value"]>
|
||||
options={options()}
|
||||
actions={[
|
||||
{
|
||||
command: "model.dialog.provider",
|
||||
title: connected() ? "Connect provider" : "View all providers",
|
||||
onTrigger() {
|
||||
dialog.replace(() => <DialogProvider />)
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "model.dialog.favorite",
|
||||
title: "Favorite",
|
||||
hidden: !connected(),
|
||||
onTrigger: (option) => {
|
||||
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
|
||||
},
|
||||
},
|
||||
]}
|
||||
onFilter={setQuery}
|
||||
flat={true}
|
||||
skipFilter={true}
|
||||
title={title()}
|
||||
current={local.model.current()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function sortModelOptions<T extends { footer?: string; releaseDate: string | number; title: string }>(
|
||||
options: T[],
|
||||
newestFirst: boolean,
|
||||
) {
|
||||
if (newestFirst) return sortBy(options, [(option) => option.releaseDate, "desc"], (option) => option.title)
|
||||
return sortBy(
|
||||
options,
|
||||
(option) => option.footer !== "Free",
|
||||
(option) => option.title,
|
||||
)
|
||||
}
|
||||
238
packages/tui/src/component/dialog-move-session.tsx
Normal file
238
packages/tui/src/component/dialog-move-session.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
||||
import path from "path"
|
||||
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSync } from "../context/sync"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { Locale } from "../util/locale"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useCommandShortcut } from "../keymap"
|
||||
import { useProject } from "../context/project"
|
||||
import { Spinner } from "./spinner"
|
||||
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||
|
||||
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
|
||||
|
||||
export function DialogMoveSession(props: {
|
||||
projectID: string
|
||||
current?: MoveSessionSelection
|
||||
onSelect: (selection: MoveSessionSelection) => void
|
||||
initialDirectories?: string[]
|
||||
initialRemoving?: string
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
const sync = useSync()
|
||||
const projectContext = useProject()
|
||||
const toast = useToast()
|
||||
const paths = useTuiPaths()
|
||||
const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [removing, setRemoving] = createSignal(props.initialRemoving)
|
||||
const deleteHint = useCommandShortcut("dialog.move_session.delete")
|
||||
|
||||
function reopen(initialRemoving?: string) {
|
||||
dialog.replace(() => (
|
||||
<DialogMoveSession {...props} initialDirectories={directories()} initialRemoving={initialRemoving} />
|
||||
))
|
||||
}
|
||||
|
||||
const [loadedProject] = createResource(
|
||||
() => (projectContext.project() === props.projectID ? undefined : props.projectID),
|
||||
async (projectID) => {
|
||||
const result = await sdk.client.project.current({}, { throwOnError: true })
|
||||
return result.data?.id === projectID ? result.data.worktree : undefined
|
||||
},
|
||||
)
|
||||
const project = createMemo(() =>
|
||||
projectContext.project() === props.projectID ? projectContext.data.project.worktree : loadedProject(),
|
||||
)
|
||||
|
||||
const [directories, { refetch }] = createResource(
|
||||
() => (props.initialRemoving ? undefined : props.projectID),
|
||||
async (projectID) => {
|
||||
setWorking(true)
|
||||
try {
|
||||
await sdk.client.experimental.projectCopy.refresh({ projectID }, { throwOnError: true })
|
||||
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
|
||||
return directories.data?.map((item) => item.directory) ?? []
|
||||
} finally {
|
||||
setWorking(false)
|
||||
}
|
||||
},
|
||||
{ initialValue: props.initialDirectories },
|
||||
)
|
||||
|
||||
const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
|
||||
const data = directories()
|
||||
const main = project()
|
||||
if (directories.loading && !data && !main) return [{ title: "Loading project directories...", value: undefined }]
|
||||
if (directories.error && !data && !main) return [{ title: "Failed to load project directories", value: undefined }]
|
||||
const roots = [...new Set(main ? [main, ...(data ?? [])] : (data ?? []))]
|
||||
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
|
||||
const subdirectories = sync.data.session
|
||||
.filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
|
||||
.map((session) => session.directory)
|
||||
.filter((directory) => !roots.includes(directory))
|
||||
.filter((directory, index, directories) => directories.indexOf(directory) === index)
|
||||
.map((location) => ({
|
||||
location,
|
||||
root: roots
|
||||
.filter((root) => {
|
||||
const relative = path.relative(root, location)
|
||||
return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
|
||||
})
|
||||
.toSorted((a, b) => b.length - a.length)[0],
|
||||
}))
|
||||
.filter((item): item is { location: string; root: string } => item.root !== undefined)
|
||||
const list = [...roots.map((location) => ({ location, root: location })), ...subdirectories].toSorted((a, b) => {
|
||||
const root = roots.indexOf(a.root) - roots.indexOf(b.root)
|
||||
if (root !== 0) return root
|
||||
if (a.location === a.root) return -1
|
||||
if (b.location === b.root) return 1
|
||||
return a.location.localeCompare(b.location)
|
||||
})
|
||||
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
|
||||
return list.map((item) => {
|
||||
const title = abbreviateHome(item.location, paths.home)
|
||||
const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location)
|
||||
const visible = Locale.truncateLeft(title, titleWidth)
|
||||
const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
|
||||
const deleting = toDelete() === item.location
|
||||
const isRemoving = removing() === item.location
|
||||
return {
|
||||
title: isRemoving ? `Deleting ${item.location}` : deleting ? `Press ${deleteHint()} again to confirm` : title,
|
||||
titleView: isRemoving ? (
|
||||
<span style={{ fg: theme.error }}>Deleting {item.location}</span>
|
||||
) : !deleting && suffix ? (
|
||||
<>
|
||||
{visible.slice(0, split)}
|
||||
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
|
||||
</>
|
||||
) : undefined,
|
||||
bg: deleting ? theme.error : undefined,
|
||||
value: { type: "directory", directory: item.location, subdirectory: item.location !== item.root } as const,
|
||||
category: item.root === main ? "Project" : "Working copies",
|
||||
titleWidth,
|
||||
truncateTitle: "left" as const,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
if (directories.loading || loadedProject.loading || !props.current) return
|
||||
if (props.current.type === "new") return props.current
|
||||
const directory = props.current.directory
|
||||
return options().find((option) => option.value?.type === "directory" && option.value.directory === directory)?.value
|
||||
})
|
||||
|
||||
async function remove(option: DialogSelectOption<MoveSessionSelection | undefined>) {
|
||||
if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return
|
||||
const data = directories()
|
||||
const main = project()
|
||||
if (!data || !main || option.value.directory === main || !data.includes(option.value.directory)) return
|
||||
if (toDelete() !== option.value.directory) {
|
||||
setToDelete(option.value.directory)
|
||||
return
|
||||
}
|
||||
setToDelete(undefined)
|
||||
setRemoving(option.value.directory)
|
||||
setWorking(true)
|
||||
const result = await sdk.client.experimental.projectCopy
|
||||
.remove({ projectID: props.projectID, directory: option.value.directory, force: false })
|
||||
.catch((error) => ({ error }))
|
||||
if (result.error) {
|
||||
setRemoving(undefined)
|
||||
setWorking(false)
|
||||
if ("data" in result.error && result.error.data.forceRequired) {
|
||||
const status = await sdk.client.vcs.status({ directory: option.value.directory }).catch(() => undefined)
|
||||
const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], {
|
||||
title: "Delete working copy?",
|
||||
message: "This working copy has file changes. Do you want to delete it anyway?",
|
||||
})
|
||||
if (choice !== "yes") {
|
||||
reopen()
|
||||
return
|
||||
}
|
||||
reopen(option.value.directory)
|
||||
const forced = await sdk.client.experimental.projectCopy
|
||||
.remove({ projectID: props.projectID, directory: option.value.directory, force: true })
|
||||
.catch((error) => ({ error }))
|
||||
if (forced.error) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete project copy",
|
||||
message: errorMessage(forced.error),
|
||||
})
|
||||
}
|
||||
reopen()
|
||||
return
|
||||
}
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete project copy",
|
||||
message: errorMessage(result.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
await refetch()
|
||||
setRemoving(undefined)
|
||||
}
|
||||
|
||||
onMount(() => dialog.setSize("xlarge"))
|
||||
|
||||
return (
|
||||
<box minHeight={Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2))}>
|
||||
<DialogSelect
|
||||
title="Move session"
|
||||
titleView={
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
Move session
|
||||
</text>
|
||||
<Show when={working()}>
|
||||
<Spinner />
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
options={options()}
|
||||
locked={directories.loading || loadedProject.loading || Boolean(removing())}
|
||||
current={current()}
|
||||
onSelect={(option) => {
|
||||
if (option.value) props.onSelect(option.value)
|
||||
}}
|
||||
onMove={() => setToDelete(undefined)}
|
||||
actions={[
|
||||
{
|
||||
command: "dialog.move_session.new",
|
||||
title: "new",
|
||||
onTrigger: () => props.onSelect({ type: "new" }),
|
||||
},
|
||||
{
|
||||
command: "dialog.move_session.delete",
|
||||
title: "delete",
|
||||
disabled: (option) =>
|
||||
!option?.value ||
|
||||
option.value.type !== "directory" ||
|
||||
option.value.subdirectory ||
|
||||
option.value.directory === project(),
|
||||
onTrigger: remove,
|
||||
},
|
||||
{
|
||||
command: "dialog.move_session.refresh",
|
||||
title: "refresh",
|
||||
onTrigger: () => void refetch(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
469
packages/tui/src/component/dialog-provider.tsx
Normal file
469
packages/tui/src/component/dialog-provider.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
import { createMemo, createSignal, onMount, Show } from "solid-js"
|
||||
import { useSync } from "../context/sync"
|
||||
import { map, pipe, sortBy } from "remeda"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { Link } from "../ui/link"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
|
||||
import { DialogModel } from "./dialog-model"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { isConsoleManagedProvider } from "../util/provider-origin"
|
||||
import { useConnected } from "./use-connected"
|
||||
import { useBindings } from "../keymap"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
|
||||
const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
opencode: 0,
|
||||
"opencode-go": 1,
|
||||
openai: 2,
|
||||
"github-copilot": 3,
|
||||
anthropic: 4,
|
||||
google: 5,
|
||||
}
|
||||
|
||||
const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__"
|
||||
const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||
|
||||
type ProviderOptionBase = {
|
||||
title: string
|
||||
value: string
|
||||
description?: string
|
||||
category: string
|
||||
}
|
||||
|
||||
type ProviderOption =
|
||||
| (ProviderOptionBase & {
|
||||
type: "provider"
|
||||
providerID: string
|
||||
})
|
||||
| (ProviderOptionBase & {
|
||||
type: "custom"
|
||||
})
|
||||
|
||||
export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] {
|
||||
return [
|
||||
...pipe(
|
||||
list,
|
||||
sortBy(
|
||||
(x) => PROVIDER_PRIORITY[x.id] ?? 99,
|
||||
(x) => x.name.toLowerCase(),
|
||||
(x) => x.id,
|
||||
),
|
||||
map((provider) => ({
|
||||
type: "provider" as const,
|
||||
title: provider.name,
|
||||
value: provider.id,
|
||||
providerID: provider.id,
|
||||
description: {
|
||||
opencode: "(Recommended)",
|
||||
anthropic: "(API key)",
|
||||
openai: "(ChatGPT Plus/Pro or API key)",
|
||||
"opencode-go": "Low cost subscription for everyone",
|
||||
}[provider.id],
|
||||
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers",
|
||||
})),
|
||||
),
|
||||
{
|
||||
type: "custom",
|
||||
title: "Other",
|
||||
value: CUSTOM_PROVIDER_OPTION_VALUE,
|
||||
description: "Custom provider",
|
||||
category: "Providers",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function normalizeCustomProviderID(value: string) {
|
||||
const providerID = value.trim().replace(/^@ai-sdk\//, "")
|
||||
if (!CUSTOM_PROVIDER_ID.test(providerID)) return
|
||||
return providerID
|
||||
}
|
||||
|
||||
export function createDialogProviderOptions() {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
const onboarded = useConnected()
|
||||
|
||||
async function promptCustomProviderID(): Promise<string | undefined> {
|
||||
const value = await DialogPrompt.show(dialog, "Other", {
|
||||
placeholder: "Provider id",
|
||||
description: () => (
|
||||
<text fg={theme.textMuted}>
|
||||
This only stores a credential. Configure the provider in opencode.json to use it.
|
||||
</text>
|
||||
),
|
||||
})
|
||||
if (value === null) return
|
||||
|
||||
const providerID = normalizeCustomProviderID(value)
|
||||
if (providerID) return providerID
|
||||
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message:
|
||||
"Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores",
|
||||
})
|
||||
return promptCustomProviderID()
|
||||
}
|
||||
|
||||
const options = createMemo(() => {
|
||||
return pipe(
|
||||
providerOptions(sync.data.provider_next.all),
|
||||
map((provider) => {
|
||||
if (provider.type === "custom") {
|
||||
return {
|
||||
title: provider.title,
|
||||
value: provider.value,
|
||||
description: provider.description,
|
||||
category: provider.category,
|
||||
async onSelect() {
|
||||
const providerID = await promptCustomProviderID()
|
||||
if (!providerID) return
|
||||
return dialog.replace(() => <ApiMethod providerID={providerID} title="API key" custom />)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const providerID = provider.providerID
|
||||
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID)
|
||||
const connected = sync.data.provider_next.connected.includes(providerID)
|
||||
|
||||
return {
|
||||
title: provider.title,
|
||||
value: provider.value,
|
||||
description: provider.description,
|
||||
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
|
||||
category: provider.category,
|
||||
gutter: connected && onboarded() ? () => <text fg={theme.success}>✓</text> : undefined,
|
||||
async onSelect() {
|
||||
if (consoleManaged) return
|
||||
|
||||
const methods = sync.data.provider_auth[providerID] ?? [
|
||||
{
|
||||
type: "api",
|
||||
label: "API key",
|
||||
},
|
||||
]
|
||||
let index: number | null = 0
|
||||
if (methods.length > 1) {
|
||||
index = await new Promise<number | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title="Select auth method"
|
||||
options={methods.map((x, index) => ({
|
||||
title: x.label,
|
||||
value: index,
|
||||
}))}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
if (index == null) return
|
||||
const method = methods[index]
|
||||
if (method.type === "oauth") {
|
||||
let inputs: Record<string, string> | undefined
|
||||
if (method.prompts?.length) {
|
||||
const value = await PromptsMethod({
|
||||
dialog,
|
||||
prompts: method.prompts,
|
||||
})
|
||||
if (!value) return
|
||||
inputs = value
|
||||
}
|
||||
|
||||
const result = await sdk.client.provider.oauth.authorize({
|
||||
providerID,
|
||||
method: index,
|
||||
inputs,
|
||||
})
|
||||
if (result.error) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message: JSON.stringify(result.error),
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
if (result.data?.method === "code") {
|
||||
dialog.replace(() => (
|
||||
<CodeMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
|
||||
))
|
||||
}
|
||||
if (result.data?.method === "auto") {
|
||||
dialog.replace(() => (
|
||||
<AutoMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
|
||||
))
|
||||
}
|
||||
}
|
||||
if (method.type === "api") {
|
||||
let metadata: Record<string, string> | undefined
|
||||
if (method.prompts?.length) {
|
||||
const value = await PromptsMethod({ dialog, prompts: method.prompts })
|
||||
if (!value) return
|
||||
metadata = value
|
||||
}
|
||||
return dialog.replace(() => (
|
||||
<ApiMethod providerID={providerID} title={method.label} metadata={metadata} />
|
||||
))
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
export function DialogProvider() {
|
||||
const options = createDialogProviderOptions()
|
||||
return <DialogSelect title="Connect a provider" options={options()} />
|
||||
}
|
||||
|
||||
interface AutoMethodProps {
|
||||
index: number
|
||||
providerID: string
|
||||
title: string
|
||||
authorization: ProviderAuthAuthorization
|
||||
}
|
||||
function AutoMethod(props: AutoMethodProps) {
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const clipboard = useClipboard()
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "c",
|
||||
desc: "Copy provider code",
|
||||
group: "Dialog",
|
||||
cmd: () => {
|
||||
const code =
|
||||
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
|
||||
clipboard
|
||||
.write?.(code)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
onMount(async () => {
|
||||
const result = await sdk.client.provider.oauth.callback({
|
||||
providerID: props.providerID,
|
||||
method: props.index,
|
||||
})
|
||||
if (result.error) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message:
|
||||
"name" in result.error && result.error.name === "ProviderAuthOauthCallbackFailed"
|
||||
? "OAuth authorization failed. Try /connect again."
|
||||
: JSON.stringify(result.error),
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
dialog.replace(() => <DialogModel providerID={props.providerID} />)
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box gap={1}>
|
||||
<Link href={props.authorization.url} fg={theme.primary} />
|
||||
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>Waiting for authorization...</text>
|
||||
<text fg={theme.text}>
|
||||
c <span style={{ fg: theme.textMuted }}>copy</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
interface CodeMethodProps {
|
||||
index: number
|
||||
title: string
|
||||
providerID: string
|
||||
authorization: ProviderAuthAuthorization
|
||||
}
|
||||
function CodeMethod(props: CodeMethodProps) {
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const [error, setError] = createSignal(false)
|
||||
|
||||
return (
|
||||
<DialogPrompt
|
||||
title={props.title}
|
||||
placeholder="Authorization code"
|
||||
onConfirm={async (value) => {
|
||||
const { error } = await sdk.client.provider.oauth.callback({
|
||||
providerID: props.providerID,
|
||||
method: props.index,
|
||||
code: value,
|
||||
})
|
||||
if (!error) {
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
dialog.replace(() => <DialogModel providerID={props.providerID} />)
|
||||
return
|
||||
}
|
||||
setError(true)
|
||||
}}
|
||||
description={() => (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
|
||||
<Link href={props.authorization.url} fg={theme.primary} />
|
||||
<Show when={error()}>
|
||||
<text fg={theme.error}>Invalid code</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ApiMethodProps {
|
||||
providerID: string
|
||||
title: string
|
||||
metadata?: Record<string, string>
|
||||
custom?: boolean
|
||||
}
|
||||
function ApiMethod(props: ApiMethodProps) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<DialogPrompt
|
||||
title={props.title}
|
||||
placeholder="API key"
|
||||
description={
|
||||
{
|
||||
opencode: (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API
|
||||
key.
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/zen</span> to get a key
|
||||
</text>
|
||||
</box>
|
||||
),
|
||||
"opencode-go": (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
OpenCode Go is a $10 per month subscription that provides reliable access to popular open coding models
|
||||
with generous usage limits.
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/go</span> and enable OpenCode Go
|
||||
</text>
|
||||
</box>
|
||||
),
|
||||
}[props.providerID] ?? undefined
|
||||
}
|
||||
onConfirm={async (value) => {
|
||||
if (!value) return
|
||||
await sdk.client.auth.set({
|
||||
providerID: props.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: value,
|
||||
...(props.metadata ? { metadata: props.metadata } : {}),
|
||||
},
|
||||
})
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
if (props.custom && !sync.data.provider_next.all.some((provider) => provider.id === props.providerID)) {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: `Saved credential for ${props.providerID}. Configure it in opencode.json to use it.`,
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
dialog.replace(() => <DialogModel providerID={props.providerID} />)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface PromptsMethodProps {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
prompts: NonNullable<ProviderAuthMethod["prompts"]>[number][]
|
||||
}
|
||||
async function PromptsMethod(props: PromptsMethodProps) {
|
||||
const inputs: Record<string, string> = {}
|
||||
for (const prompt of props.prompts) {
|
||||
if (prompt.when) {
|
||||
const value = inputs[prompt.when.key]
|
||||
if (value === undefined) continue
|
||||
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
|
||||
if (!matches) continue
|
||||
}
|
||||
|
||||
if (prompt.type === "select") {
|
||||
const value = await new Promise<string | null>((resolve) => {
|
||||
props.dialog.replace(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title={prompt.message}
|
||||
options={prompt.options.map((x) => ({
|
||||
title: x.label,
|
||||
value: x.value,
|
||||
description: x.hint,
|
||||
}))}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
if (value === null) return null
|
||||
inputs[prompt.key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
const value = await new Promise<string | null>((resolve) => {
|
||||
props.dialog.replace(
|
||||
() => (
|
||||
<DialogPrompt title={prompt.message} placeholder={prompt.placeholder} onConfirm={(value) => resolve(value)} />
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
if (value === null) return null
|
||||
inputs[prompt.key] = value
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
160
packages/tui/src/component/dialog-retry-action.tsx
Normal file
160
packages/tui/src/component/dialog-retry-action.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { createSignal } from "solid-js"
|
||||
import { selectedForeground, useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { Link } from "../ui/link"
|
||||
import { BgPulse } from "./bg-pulse"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
const GO_URL = "https://opencode.ai/go"
|
||||
const PAD_X = 3
|
||||
const PAD_TOP_OUTER = 1
|
||||
const FOREGROUND_ALPHA = 186
|
||||
|
||||
export type DialogRetryActionProps = {
|
||||
title: string
|
||||
message: string
|
||||
label: string
|
||||
link?: string
|
||||
onClose?: (dontShowAgain?: boolean) => void
|
||||
}
|
||||
|
||||
function runAction(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
|
||||
if (props.link) open(props.link).catch(() => {})
|
||||
props.onClose?.()
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
function dismiss(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
|
||||
props.onClose?.(true)
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
function panelOverlay(color: RGBA) {
|
||||
const [r, g, b] = color.toInts()
|
||||
return RGBA.fromInts(r, g, b, FOREGROUND_ALPHA)
|
||||
}
|
||||
|
||||
export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const fg = selectedForeground(theme)
|
||||
const showGoTreatment = () => props.link === GO_URL
|
||||
const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined)
|
||||
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "left",
|
||||
desc: "Previous retry option",
|
||||
group: "Dialog",
|
||||
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||
},
|
||||
{
|
||||
key: "right",
|
||||
desc: "Next retry option",
|
||||
group: "Dialog",
|
||||
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||
},
|
||||
{
|
||||
key: "tab",
|
||||
desc: "Next retry option",
|
||||
group: "Dialog",
|
||||
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||
},
|
||||
{
|
||||
key: "return",
|
||||
desc: "Confirm retry option",
|
||||
group: "Dialog",
|
||||
cmd: () => {
|
||||
if (selected() === "action") runAction(props, dialog)
|
||||
else dismiss(props, dialog)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box>
|
||||
{showGoTreatment() ? (
|
||||
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
|
||||
<BgPulse />
|
||||
</box>
|
||||
) : null}
|
||||
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text} bg={textBg()}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={theme.textMuted} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box gap={0}>
|
||||
<text fg={theme.textMuted} bg={textBg()}>
|
||||
{props.message}
|
||||
</text>
|
||||
</box>
|
||||
{props.link ? (
|
||||
showGoTreatment() ? (
|
||||
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
|
||||
<Link href={props.link} fg={theme.primary} bg={textBg()} wrapMode="none" />
|
||||
</box>
|
||||
) : (
|
||||
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
|
||||
<Link href={props.link} fg={theme.primary} wrapMode="none" />
|
||||
</box>
|
||||
)
|
||||
) : (
|
||||
<box paddingBottom={1} />
|
||||
)}
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setSelected("dismiss")}
|
||||
onMouseUp={() => dismiss(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "dismiss" ? fg : theme.textMuted}
|
||||
bg={selected() === "dismiss" ? undefined : textBg()}
|
||||
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
don't show again
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={selected() === "action" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setSelected("action")}
|
||||
onMouseUp={() => runAction(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "action" ? fg : theme.text}
|
||||
bg={selected() === "action" ? undefined : textBg()}
|
||||
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{props.label}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogRetryAction.show = (
|
||||
dialog: DialogContext,
|
||||
props: Pick<DialogRetryActionProps, "title" | "message" | "label" | "link">,
|
||||
) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
dialog.replace(
|
||||
() => <DialogRetryAction {...props} onClose={(dontShow) => resolve(dontShow ?? false)} />,
|
||||
() => resolve(false),
|
||||
)
|
||||
})
|
||||
}
|
||||
99
packages/tui/src/component/dialog-session-delete-failed.tsx
Normal file
99
packages/tui/src/component/dialog-session-delete-failed.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
export function DialogSessionDeleteFailed(props: {
|
||||
session: string
|
||||
workspace: string
|
||||
onDelete?: () => boolean | void | Promise<boolean | void>
|
||||
onRestore?: () => boolean | void | Promise<boolean | void>
|
||||
onDone?: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const [store, setStore] = createStore({
|
||||
active: "delete" as "delete" | "restore",
|
||||
})
|
||||
|
||||
const options = [
|
||||
{
|
||||
id: "delete" as const,
|
||||
title: "Delete workspace",
|
||||
description: "Delete the workspace and all sessions attached to it.",
|
||||
run: props.onDelete,
|
||||
},
|
||||
{
|
||||
id: "restore" as const,
|
||||
title: "Restore to new workspace",
|
||||
description: "Try to restore this session into a new workspace.",
|
||||
run: props.onRestore,
|
||||
},
|
||||
]
|
||||
|
||||
async function confirm() {
|
||||
const result = await options.find((item) => item.id === store.active)?.run?.()
|
||||
if (result === false) return
|
||||
props.onDone?.()
|
||||
if (!props.onDone) dialog.clear()
|
||||
}
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{ key: "return", desc: "Confirm recovery option", group: "Dialog", cmd: () => void confirm() },
|
||||
{ key: "left", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") },
|
||||
{ key: "up", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") },
|
||||
{ key: "right", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") },
|
||||
{ key: "down", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Failed to Delete Session
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
|
||||
</text>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Choose how you want to recover this broken workspace session.
|
||||
</text>
|
||||
<box flexDirection="column" paddingBottom={1} gap={1}>
|
||||
<For each={options}>
|
||||
{(item) => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={item.id === store.active ? theme.primary : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item.id)
|
||||
void confirm()
|
||||
}}
|
||||
>
|
||||
<text
|
||||
attributes={TextAttributes.BOLD}
|
||||
fg={item.id === store.active ? theme.selectedListItemText : theme.text}
|
||||
>
|
||||
{item.title}
|
||||
</text>
|
||||
<text fg={item.id === store.active ? theme.selectedListItemText : theme.textMuted} wrapMode="word">
|
||||
{item.description}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
306
packages/tui/src/component/dialog-session-list.tsx
Normal file
306
packages/tui/src/component/dialog-session-list.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useSync } from "../context/sync"
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import path from "path"
|
||||
import { Locale } from "../util/locale"
|
||||
import { useProject } from "../context/project"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useLocal } from "../context/local"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
|
||||
import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
|
||||
import { useCommandShortcut } from "../keymap"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const local = useLocal()
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const deleteHint = useCommandShortcut("session.delete")
|
||||
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
|
||||
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
|
||||
|
||||
const [searchResults, { refetch }] = createResource(
|
||||
() => ({ query: search(), filter: sync.session.query() }),
|
||||
async (input) => {
|
||||
if (!input.query) return undefined
|
||||
const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter })
|
||||
return result.data ?? []
|
||||
},
|
||||
)
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
||||
|
||||
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
|
||||
const workspace = project.workspace.get(session.workspaceID!)
|
||||
const list = () => dialog.replace(() => <DialogSessionList />)
|
||||
const warp = async (selection: WorkspaceSelection) => {
|
||||
const workspaceID = await (async () => {
|
||||
if (selection.type === "none") return null
|
||||
if (selection.type === "existing") return selection.workspaceID
|
||||
let result
|
||||
try {
|
||||
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
|
||||
} catch (err) {
|
||||
toast.show({
|
||||
title: "Failed to create workspace",
|
||||
message: errorMessage(err),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
const workspace = result?.data
|
||||
if (!workspace) {
|
||||
toast.show({
|
||||
title: "Failed to create workspace",
|
||||
message: errorMessage(result?.error ?? "no response"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
await project.workspace.sync()
|
||||
return workspace.id
|
||||
})()
|
||||
if (workspaceID === undefined) return
|
||||
await warpWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
sourceWorkspaceID: session.workspaceID,
|
||||
workspaceID,
|
||||
sessionID: session.id,
|
||||
copyChanges: false,
|
||||
done: list,
|
||||
})
|
||||
}
|
||||
dialog.replace(() => (
|
||||
<DialogSessionDeleteFailed
|
||||
session={session.title}
|
||||
workspace={workspace?.name ?? session.workspaceID!}
|
||||
onDone={list}
|
||||
onDelete={async () => {
|
||||
const current = currentSessionID()
|
||||
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
|
||||
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
|
||||
if (result.error) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete workspace",
|
||||
message: errorMessage(result.error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
await project.workspace.sync()
|
||||
await sync.session.refresh()
|
||||
if (search()) await refetch()
|
||||
if (info?.workspaceID === session.workspaceID) {
|
||||
route.navigate({ type: "home" })
|
||||
}
|
||||
return true
|
||||
}}
|
||||
onRestore={() => {
|
||||
void openWorkspaceSelect({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warp(selection)
|
||||
},
|
||||
})
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
|
||||
return sessionsList
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => x.id)
|
||||
}
|
||||
|
||||
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
|
||||
|
||||
const quickSwitchHint = createMemo(() => {
|
||||
const first = quickSwitch1()
|
||||
const last = quickSwitch9()
|
||||
if (!first || !last) return undefined
|
||||
return quickSwitchRange(first, last)
|
||||
})
|
||||
const quickSwitchFooterHints = createMemo(() => {
|
||||
const hint = quickSwitchHint()
|
||||
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
const sessionMap = new Map(
|
||||
sessions()
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.map((x) => [x.id, x]),
|
||||
)
|
||||
|
||||
const searchResult = searchResults()
|
||||
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
|
||||
|
||||
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
|
||||
const pinnedSet = new Set(pinned)
|
||||
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
|
||||
|
||||
function buildOption(id: string, category: string) {
|
||||
const x = sessionMap.get(id)
|
||||
if (!x) return undefined
|
||||
const directory = x.path
|
||||
? x.directory.endsWith(x.path)
|
||||
? x.directory.slice(0, -x.path.length).replace(/\/$/, "")
|
||||
: undefined
|
||||
: x.directory
|
||||
const footer =
|
||||
directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
|
||||
|
||||
const isDeleting = toDelete() === x.id
|
||||
const status = sync.data.session_status?.[x.id]
|
||||
const isWorking = status?.type === "busy" || status?.type === "retry"
|
||||
const slot = slotByID.get(x.id)
|
||||
const gutter = isWorking
|
||||
? () => <Spinner />
|
||||
: slot !== undefined
|
||||
? () => <text fg={theme.accent}>{slot}</text>
|
||||
: undefined
|
||||
return {
|
||||
title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
footer,
|
||||
gutter,
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = displayOrder
|
||||
.filter((id) => !pinnedSet.has(id))
|
||||
.map((id) => {
|
||||
const x = sessionMap.get(id)
|
||||
if (!x) return undefined
|
||||
const label = new Date(x.time.updated).toDateString()
|
||||
return buildOption(id, label === today ? "Today" : label)
|
||||
})
|
||||
.filter((x) => x !== undefined)
|
||||
|
||||
return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining]
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Sessions"
|
||||
options={options()}
|
||||
skipFilter={true}
|
||||
current={currentSessionID()}
|
||||
onFilter={setSearch}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: option.value,
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
command: "session.pin.toggle",
|
||||
title: "pin/unpin",
|
||||
onTrigger: (option: { value: string }) => {
|
||||
local.session.togglePin(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "session.delete",
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
const session = sessions().find((item) => item.id === option.value)
|
||||
const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
|
||||
|
||||
try {
|
||||
const result = await sdk.client.session.delete({
|
||||
sessionID: option.value,
|
||||
})
|
||||
if (result.error) {
|
||||
if (session?.workspaceID) {
|
||||
recover(session)
|
||||
} else {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete session",
|
||||
message: errorMessage(result.error),
|
||||
})
|
||||
}
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
if (session?.workspaceID) {
|
||||
recover(session)
|
||||
} else {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete session",
|
||||
message: errorMessage(err),
|
||||
})
|
||||
}
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
if (status && status !== "connected") {
|
||||
await sync.session.refresh()
|
||||
}
|
||||
if (search()) await refetch()
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
setToDelete(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "session.rename",
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
},
|
||||
]}
|
||||
footerHints={quickSwitchFooterHints()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function quickSwitchRange(first: string, last: string) {
|
||||
const prefix = first.slice(0, -1)
|
||||
if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
|
||||
return `${first} through ${last}`
|
||||
}
|
||||
31
packages/tui/src/component/dialog-session-rename.tsx
Normal file
31
packages/tui/src/component/dialog-session-rename.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSync } from "../context/sync"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSDK } from "../context/sdk"
|
||||
|
||||
interface DialogSessionRenameProps {
|
||||
session: string
|
||||
}
|
||||
|
||||
export function DialogSessionRename(props: DialogSessionRenameProps) {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const session = createMemo(() => sync.session.get(props.session))
|
||||
|
||||
return (
|
||||
<DialogPrompt
|
||||
title="Rename Session"
|
||||
value={session()?.title}
|
||||
onConfirm={(value) => {
|
||||
void sdk.client.session.update({
|
||||
sessionID: props.session,
|
||||
title: value,
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
onCancel={() => dialog.clear()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
36
packages/tui/src/component/dialog-skill.tsx
Normal file
36
packages/tui/src/component/dialog-skill.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { createResource, createMemo } from "solid-js"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
|
||||
export type DialogSkillProps = {
|
||||
onSelect: (skill: string) => void
|
||||
}
|
||||
|
||||
export function DialogSkill(props: DialogSkillProps) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
dialog.setSize("large")
|
||||
|
||||
const [skills] = createResource(async () => {
|
||||
const result = await sdk.client.app.skills()
|
||||
return result.data ?? []
|
||||
})
|
||||
|
||||
const options = createMemo<DialogSelectOption<string>[]>(() => {
|
||||
const list = skills() ?? []
|
||||
const maxWidth = Math.max(0, ...list.map((s) => s.name.length))
|
||||
return list.map((skill) => ({
|
||||
title: skill.name.padEnd(maxWidth),
|
||||
description: skill.description?.replace(/\s+/g, " ").trim(),
|
||||
value: skill.name,
|
||||
category: "Skills",
|
||||
onSelect: () => {
|
||||
props.onSelect(skill.name)
|
||||
dialog.clear()
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
return <DialogSelect title="Skills" placeholder="Search skills..." options={options()} />
|
||||
}
|
||||
87
packages/tui/src/component/dialog-stash.tsx
Normal file
87
packages/tui/src/component/dialog-stash.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { Locale } from "../util/locale"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { usePromptStash, type StashEntry } from "./prompt/stash"
|
||||
import { useCommandShortcut } from "../keymap"
|
||||
|
||||
function getRelativeTime(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (seconds < 60) return "just now"
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
if (days < 7) return `${days}d ago`
|
||||
return Locale.datetime(timestamp)
|
||||
}
|
||||
|
||||
function getStashPreview(input: string, maxLength: number = 50): string {
|
||||
const firstLine = input.split("\n")[0].trim()
|
||||
return Locale.truncate(firstLine, maxLength)
|
||||
}
|
||||
|
||||
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
const dialog = useDialog()
|
||||
const stash = usePromptStash()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<number>()
|
||||
const deleteHint = useCommandShortcut("stash.delete")
|
||||
|
||||
const options = createMemo(() => {
|
||||
const entries = stash.list()
|
||||
// Show most recent first
|
||||
return entries
|
||||
.map((entry, index) => {
|
||||
const isDeleting = toDelete() === index
|
||||
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
|
||||
return {
|
||||
title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input),
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: index,
|
||||
description: getRelativeTime(entry.timestamp),
|
||||
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
|
||||
}
|
||||
})
|
||||
.toReversed()
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Stash"
|
||||
options={options()}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
const entries = stash.list()
|
||||
const entry = entries[option.value]
|
||||
if (entry) {
|
||||
stash.remove(option.value)
|
||||
props.onSelect(entry)
|
||||
}
|
||||
dialog.clear()
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
command: "stash.delete",
|
||||
title: "delete",
|
||||
onTrigger: (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
stash.remove(option.value)
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
setToDelete(option.value)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
168
packages/tui/src/component/dialog-status.tsx
Normal file
168
packages/tui/src/component/dialog-status.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { fileURLToPath } from "bun"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSync } from "../context/sync"
|
||||
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||
|
||||
export type DialogStatusProps = {}
|
||||
|
||||
export function DialogStatus() {
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const dialog = useDialog()
|
||||
|
||||
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
|
||||
|
||||
const plugins = createMemo(() => {
|
||||
const list = sync.data.config.plugin ?? []
|
||||
const result = list.map((item) => {
|
||||
const value = typeof item === "string" ? item : item[0]
|
||||
if (value.startsWith("file://")) {
|
||||
const path = fileURLToPath(value)
|
||||
const parts = path.split("/")
|
||||
const filename = parts.pop() || path
|
||||
if (!filename.includes(".")) return { name: filename }
|
||||
const basename = filename.split(".")[0]
|
||||
if (basename === "index") {
|
||||
const dirname = parts.pop()
|
||||
const name = dirname || basename
|
||||
return { name }
|
||||
}
|
||||
return { name: basename }
|
||||
}
|
||||
const index = value.lastIndexOf("@")
|
||||
if (index <= 0) return { name: value, version: "latest" }
|
||||
const name = value.substring(0, index)
|
||||
const version = value.substring(index + 1)
|
||||
return { name, version }
|
||||
})
|
||||
return result.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
Status
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}>
|
||||
<box>
|
||||
<text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>
|
||||
<For each={Object.entries(sync.data.mcp)}>
|
||||
{([key, item]) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: (
|
||||
{
|
||||
connected: theme.success,
|
||||
failed: theme.error,
|
||||
disabled: theme.textMuted,
|
||||
needs_auth: theme.warning,
|
||||
needs_client_registration: theme.error,
|
||||
} as Record<string, typeof theme.success>
|
||||
)[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
<b>{key}</b>{" "}
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
<Switch fallback={item.status}>
|
||||
<Match when={item.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
|
||||
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
|
||||
<Match when={(item.status as string) === "needs_auth"}>
|
||||
Needs authentication (run: opencode mcp auth {key})
|
||||
</Match>
|
||||
<Match when={(item.status as string) === "needs_client_registration" && item}>
|
||||
{(val) => (val() as { error: string }).error}
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
{sync.data.lsp.length > 0 && (
|
||||
<box>
|
||||
<text fg={theme.text}>{sync.data.lsp.length} LSP Servers</text>
|
||||
<For each={sync.data.lsp}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: theme.success,
|
||||
error: theme.error,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)}
|
||||
<Show when={enabledFormatters().length > 0} fallback={<text fg={theme.text}>No Formatters</text>}>
|
||||
<box>
|
||||
<text fg={theme.text}>{enabledFormatters().length} Formatters</text>
|
||||
<For each={enabledFormatters()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: theme.success,
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text wrapMode="word" fg={theme.text}>
|
||||
<b>{item.name}</b>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={plugins().length > 0} fallback={<text fg={theme.text}>No Plugins</text>}>
|
||||
<box>
|
||||
<text fg={theme.text}>{plugins().length} Plugins</text>
|
||||
<For each={plugins()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: theme.success,
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text wrapMode="word" fg={theme.text}>
|
||||
<b>{item.name}</b>
|
||||
{item.version && <span style={{ fg: theme.textMuted }}> @{item.version}</span>}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
47
packages/tui/src/component/dialog-tag.tsx
Normal file
47
packages/tui/src/component/dialog-tag.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useProject } from "../context/project"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
const project = useProject()
|
||||
|
||||
const [store] = createStore({
|
||||
filter: "",
|
||||
})
|
||||
|
||||
const [files] = createResource(
|
||||
() => [store.filter],
|
||||
async () => {
|
||||
const result = await sdk.client.find.files({
|
||||
query: store.filter,
|
||||
workspace: project.workspace.current(),
|
||||
})
|
||||
if (result.error) return []
|
||||
const sliced = (result.data ?? []).slice(0, 5)
|
||||
return sliced
|
||||
},
|
||||
)
|
||||
|
||||
const options = createMemo(() =>
|
||||
(files() ?? []).map((file) => ({
|
||||
value: file,
|
||||
title: file,
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Autocomplete"
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
props.onSelect?.(option.value)
|
||||
dialog.clear()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
50
packages/tui/src/component/dialog-theme-list.tsx
Normal file
50
packages/tui/src/component/dialog-theme-list.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { onCleanup } from "solid-js"
|
||||
|
||||
export function DialogThemeList() {
|
||||
const theme = useTheme()
|
||||
const options = Object.keys(theme.all())
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
.map((value) => ({
|
||||
title: value,
|
||||
value: value,
|
||||
}))
|
||||
const dialog = useDialog()
|
||||
let confirmed = false
|
||||
let ref: DialogSelectRef<string>
|
||||
const initial = theme.selected
|
||||
|
||||
onCleanup(() => {
|
||||
if (!confirmed) theme.set(initial)
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Themes"
|
||||
options={options}
|
||||
current={initial}
|
||||
onMove={(opt) => {
|
||||
theme.set(opt.value)
|
||||
}}
|
||||
onSelect={(opt) => {
|
||||
theme.set(opt.value)
|
||||
confirmed = true
|
||||
dialog.clear()
|
||||
}}
|
||||
ref={(r) => {
|
||||
ref = r
|
||||
}}
|
||||
onFilter={(query) => {
|
||||
if (query.length === 0) {
|
||||
theme.set(initial)
|
||||
return
|
||||
}
|
||||
|
||||
const first = ref.filtered[0]
|
||||
if (first) theme.set(first.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
39
packages/tui/src/component/dialog-variant.tsx
Normal file
39
packages/tui/src/component/dialog-variant.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useLocal } from "../context/local"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
|
||||
export function DialogVariant() {
|
||||
const local = useLocal()
|
||||
const dialog = useDialog()
|
||||
|
||||
const options = createMemo(() => {
|
||||
return [
|
||||
{
|
||||
value: "default",
|
||||
title: "Default",
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(undefined)
|
||||
},
|
||||
},
|
||||
...local.model.variant.list().map((variant) => ({
|
||||
value: variant,
|
||||
title: variant,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(variant)
|
||||
},
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect<string>
|
||||
options={options()}
|
||||
title={"Select variant"}
|
||||
current={local.model.variant.selected()}
|
||||
flat={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
308
packages/tui/src/component/dialog-workspace-create.tsx
Normal file
308
packages/tui/src/component/dialog-workspace-create.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import type { ExperimentalWorkspaceAdapterListResponse, Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useSync } from "../context/sync"
|
||||
import { useProject } from "../context/project"
|
||||
import { useRoute } from "../context/route"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||
|
||||
type Adapter = ExperimentalWorkspaceAdapterListResponse[number]
|
||||
|
||||
export type WorkspaceSelection =
|
||||
| {
|
||||
type: "none"
|
||||
}
|
||||
| {
|
||||
type: "new"
|
||||
workspaceType: string
|
||||
workspaceName: string
|
||||
}
|
||||
| {
|
||||
type: "existing"
|
||||
workspaceID: string
|
||||
workspaceType: string
|
||||
workspaceName: string
|
||||
}
|
||||
|
||||
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
|
||||
type ExistingWorkspaceSelectValue = { workspace: Workspace }
|
||||
|
||||
export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string; timeUsed: number | string }>(input: {
|
||||
workspaces: readonly WorkspaceInfo[]
|
||||
status: (workspaceID: string) => string | undefined
|
||||
limit?: number
|
||||
omitWorkspaceID?: string
|
||||
}) {
|
||||
const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected")
|
||||
const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed))
|
||||
const recent = workspaces.slice(0, input.limit ?? 3)
|
||||
|
||||
return { recent, hasMore: recent.length < workspaces.length }
|
||||
}
|
||||
|
||||
export function warpReminderText(dir: string) {
|
||||
return `<system-reminder>The user has changed the current working directory to "${dir}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
|
||||
}
|
||||
|
||||
async function loadWorkspaceAdapters(input: {
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
}) {
|
||||
const dir = input.sync.path.directory || input.sdk.directory
|
||||
try {
|
||||
const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir })
|
||||
if (response.error) throw response.error
|
||||
return response.data
|
||||
} catch (err) {
|
||||
input.toast.show({
|
||||
title: "Failed to load workspace adapters",
|
||||
message: errorMessage(err),
|
||||
variant: "error",
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function openWorkspaceSelect(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
project: ReturnType<typeof useProject>
|
||||
toast: ReturnType<typeof useToast>
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
input.dialog.clear()
|
||||
await input.sdk.client.experimental.workspace.syncList().catch(() => undefined)
|
||||
await input.project.workspace.sync().catch(() => undefined)
|
||||
const adapters = await loadWorkspaceAdapters(input)
|
||||
if (!adapters) return
|
||||
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
|
||||
}
|
||||
|
||||
export async function warpWorkspaceSession(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
project: ReturnType<typeof useProject>
|
||||
toast: ReturnType<typeof useToast>
|
||||
sourceWorkspaceID?: string
|
||||
workspaceID: string | null
|
||||
sessionID: string
|
||||
copyChanges: boolean
|
||||
done?: () => void
|
||||
}): Promise<boolean> {
|
||||
let result
|
||||
try {
|
||||
result = await input.sdk.client.experimental.workspace.warp({
|
||||
id: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
copyChanges: input.copyChanges,
|
||||
})
|
||||
} catch (err) {
|
||||
input.toast.show({
|
||||
title: "Failed to warp session",
|
||||
message: errorMessage(err),
|
||||
variant: "error",
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!result?.data) {
|
||||
if (result?.error && "name" in result.error && result.error.name === "VcsApplyError") {
|
||||
await DialogAlert.show(
|
||||
input.dialog,
|
||||
"Unable to Warp Session",
|
||||
"Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.",
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
input.toast.show({
|
||||
title: "Failed to warp session",
|
||||
message: errorMessage(result?.error ?? "no response"),
|
||||
variant: "error",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
input.project.workspace.set(input.workspaceID)
|
||||
|
||||
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
|
||||
const dir = input.project.instance.directory() || input.sync.path.directory
|
||||
if (dir) {
|
||||
await input.sdk.client.session
|
||||
.promptAsync({
|
||||
sessionID: input.sessionID,
|
||||
workspace: input.workspaceID ?? undefined,
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: warpReminderText(dir),
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
|
||||
|
||||
if (input.done) {
|
||||
input.done()
|
||||
return true
|
||||
}
|
||||
input.dialog.clear()
|
||||
return true
|
||||
}
|
||||
|
||||
export async function confirmWorkspaceFileChanges(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sourceWorkspaceID?: string
|
||||
}) {
|
||||
const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined)
|
||||
const fileChangeChoice = status?.data?.length
|
||||
? await DialogWorkspaceFileChanges.show(input.dialog, status.data)
|
||||
: "no"
|
||||
if (!fileChangeChoice) return
|
||||
return fileChangeChoice === "yes"
|
||||
}
|
||||
|
||||
export function DialogWorkspaceSelect(props: {
|
||||
adapters?: Adapter[]
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const project = useProject()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
|
||||
const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined))
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
void (async () => {
|
||||
if (adapters()) return
|
||||
const res = await loadWorkspaceAdapters({ sdk, sync, toast })
|
||||
if (!res) return
|
||||
setAdapters(res)
|
||||
})()
|
||||
})
|
||||
|
||||
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
|
||||
const list = adapters()
|
||||
if (!list) return []
|
||||
const { recent, hasMore } = recentConnectedWorkspaces({
|
||||
workspaces: project.workspace.list(),
|
||||
status: project.workspace.status,
|
||||
omitWorkspaceID: omittedWorkspaceID(),
|
||||
})
|
||||
return [
|
||||
...list.map((adapter) => ({
|
||||
title: adapter.name,
|
||||
value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name },
|
||||
description: adapter.description,
|
||||
category: "New workspace",
|
||||
})),
|
||||
{
|
||||
title: "None",
|
||||
value: { type: "none" as const },
|
||||
description: "Use the local project",
|
||||
category: "Choose workspace",
|
||||
},
|
||||
...recent.map((workspace: Workspace) => ({
|
||||
title: workspace.name,
|
||||
description: `(${workspace.type})`,
|
||||
value: {
|
||||
type: "existing" as const,
|
||||
workspaceID: workspace.id,
|
||||
workspaceType: workspace.type,
|
||||
workspaceName: workspace.name,
|
||||
},
|
||||
category: "Choose workspace",
|
||||
})),
|
||||
...(hasMore
|
||||
? [
|
||||
{
|
||||
title: "View all workspaces",
|
||||
value: { type: "existing-list" as const },
|
||||
description: "Choose from all workspaces",
|
||||
category: "Choose workspace",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
})
|
||||
|
||||
if (!adapters()) return null
|
||||
return (
|
||||
<DialogSelect<WorkspaceSelectValue>
|
||||
title="Warp"
|
||||
skipFilter={true}
|
||||
renderFilter={false}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (!option.value) return
|
||||
if (option.value.type === "none") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
if (option.value.type === "new") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
if (option.value.type === "existing") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
|
||||
dialog.replace(() => (
|
||||
<DialogExistingWorkspaceSelect omitWorkspaceID={omittedWorkspaceID()} onSelect={props.onSelect} />
|
||||
))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogExistingWorkspaceSelect(props: {
|
||||
omitWorkspaceID?: string
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
const project = useProject()
|
||||
|
||||
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
|
||||
project.workspace
|
||||
.list()
|
||||
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
|
||||
.filter((workspace) => workspace.id !== props.omitWorkspaceID)
|
||||
.map((workspace: Workspace) => ({
|
||||
title: workspace.name,
|
||||
description: `(${workspace.type})`,
|
||||
value: { workspace },
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect<ExistingWorkspaceSelectValue>
|
||||
title="Existing Workspace"
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
void props.onSelect({
|
||||
type: "existing",
|
||||
workspaceID: option.value.workspace.id,
|
||||
workspaceType: option.value.workspace.type,
|
||||
workspaceName: option.value.workspace.name,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
144
packages/tui/src/component/dialog-workspace-file-changes.tsx
Normal file
144
packages/tui/src/component/dialog-workspace-file-changes.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import type { VcsFileStatus } from "@opencode-ai/sdk/v2"
|
||||
import { createMemo, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Locale } from "../util/locale"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useTuiConfig } from "../config"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
|
||||
const options = ["no", "yes"] as const
|
||||
|
||||
export type WorkspaceFileChangesChoice = (typeof options)[number]
|
||||
|
||||
function statusLabel(status: VcsFileStatus["status"]) {
|
||||
if (status === "added") return "A"
|
||||
if (status === "deleted") return "D"
|
||||
return "M"
|
||||
}
|
||||
|
||||
function changeCountWidth(file: VcsFileStatus) {
|
||||
// The "plus 2" is for spaces
|
||||
return `${file.additions ? `+${file.additions}` : ""}${file.deletions ? ` -${file.deletions}` : ""}`.length + 2
|
||||
}
|
||||
|
||||
export function DialogWorkspaceFileChanges(props: {
|
||||
files: VcsFileStatus[]
|
||||
onSelect: (choice: WorkspaceFileChangesChoice) => void
|
||||
title?: string
|
||||
message?: string
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
|
||||
const height = createMemo(() => Math.min(props.files.length, 8))
|
||||
const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0))
|
||||
|
||||
function confirm() {
|
||||
props.onSelect(store.active)
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
confirm()
|
||||
return
|
||||
}
|
||||
if (evt.name === "left") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
const index = options.indexOf(store.active)
|
||||
setStore("active", options[Math.max(index - 1, 0)])
|
||||
return
|
||||
}
|
||||
if (evt.name === "right") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
const index = options.indexOf(store.active)
|
||||
setStore("active", options[Math.min(index + 1, options.length - 1)])
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
{props.title ?? "File Changes Found"}
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
{props.message ?? "Do you want to move these changes with the session?"}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height={height()}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
<For each={props.files}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<box flexDirection="row" minWidth={0} flexShrink={1}>
|
||||
<box width={2} flexShrink={0}>
|
||||
<text fg={theme.textMuted}>{statusLabel(item.status)}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{Locale.truncateLeft(item.file, fileNameWidth())}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
|
||||
<text>
|
||||
{" "}
|
||||
{item.additions ? <span style={{ fg: theme.diffAdded }}>+{item.additions}</span> : null}
|
||||
{item.deletions ? <span style={{ fg: theme.diffRemoved }}> -{item.deletions}</span> : null}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
|
||||
<For each={options}>
|
||||
{(item) => (
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={item === store.active ? theme.primary : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item)
|
||||
props.onSelect(item)
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogWorkspaceFileChanges.show = (
|
||||
dialog: DialogContext,
|
||||
files: VcsFileStatus[],
|
||||
options?: { title?: string; message?: string },
|
||||
) => {
|
||||
return new Promise<WorkspaceFileChangesChoice | undefined>((resolve) => {
|
||||
dialog.replace(
|
||||
() => <DialogWorkspaceFileChanges files={files} onSelect={resolve} {...options} />,
|
||||
() => resolve(undefined),
|
||||
)
|
||||
})
|
||||
}
|
||||
112
packages/tui/src/component/dialog-workspace-list.tsx
Normal file
112
packages/tui/src/component/dialog-workspace-list.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useProject } from "../context/project"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useSync } from "../context/sync"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type WorkspaceOption = { workspace: Workspace }
|
||||
|
||||
export function DialogWorkspaceList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const project = useProject()
|
||||
const { theme } = useTheme()
|
||||
const [deleting, setDeleting] = createSignal<string>()
|
||||
const [removing, setRemoving] = createSignal<string>()
|
||||
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
|
||||
|
||||
const current = createMemo(() => {
|
||||
if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID
|
||||
return project.workspace.current()
|
||||
})
|
||||
|
||||
const options = createMemo<DialogSelectOption<WorkspaceOption>[]>(() =>
|
||||
project.workspace
|
||||
.list()
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.map((workspace) => {
|
||||
const status = project.workspace.status(workspace.id)
|
||||
return {
|
||||
title:
|
||||
removing() === workspace.id
|
||||
? "Deleting..."
|
||||
: deleting() === workspace.id
|
||||
? `Delete ${workspace.name}? Press delete again`
|
||||
: workspace.name,
|
||||
value: { workspace },
|
||||
footer: workspace.type,
|
||||
details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined,
|
||||
gutter: () => <text fg={status === "connected" ? theme.success : theme.error}>●</text>,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
function showDetails(workspace: Workspace) {
|
||||
setExpanded(workspace.id, (open) => !open)
|
||||
}
|
||||
|
||||
async function remove(workspace: Workspace) {
|
||||
if (removing()) return
|
||||
if (deleting() !== workspace.id) {
|
||||
setDeleting(workspace.id)
|
||||
return
|
||||
}
|
||||
|
||||
setDeleting(undefined)
|
||||
setRemoving(workspace.id)
|
||||
const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({
|
||||
error: err,
|
||||
}))
|
||||
if (result?.error) {
|
||||
setRemoving(undefined)
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete workspace",
|
||||
message: errorMessage(result.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (current() === workspace.id) {
|
||||
project.workspace.set(undefined)
|
||||
route.navigate({ type: "home" })
|
||||
}
|
||||
await project.workspace.sync()
|
||||
await sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
setRemoving(undefined)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
void sdk.client.experimental.workspace.syncList().catch(() => undefined)
|
||||
void project.workspace.sync()
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Workspaces"
|
||||
options={options()}
|
||||
onMove={(option) => {
|
||||
setDeleting(undefined)
|
||||
}}
|
||||
onSelect={(option) => showDetails(option.value.workspace)}
|
||||
actions={[
|
||||
{
|
||||
command: "session.delete",
|
||||
title: "delete",
|
||||
onTrigger: (option) => void remove(option.value.workspace),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
69
packages/tui/src/component/dialog-workspace-unavailable.tsx
Normal file
69
packages/tui/src/component/dialog-workspace-unavailable.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const [store, setStore] = createStore({
|
||||
active: "restore" as "cancel" | "restore",
|
||||
})
|
||||
|
||||
const options = ["cancel", "restore"] as const
|
||||
|
||||
async function confirm() {
|
||||
if (store.active === "cancel") {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const result = await props.onRestore?.()
|
||||
if (result === false) return
|
||||
}
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{ key: "return", desc: "Confirm workspace option", group: "Dialog", cmd: () => void confirm() },
|
||||
{ key: "left", desc: "Cancel workspace restore", group: "Dialog", cmd: () => setStore("active", "cancel") },
|
||||
{ key: "right", desc: "Restore workspace", group: "Dialog", cmd: () => setStore("active", "restore") },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Workspace Unavailable
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
This session is attached to a workspace that is no longer available.
|
||||
</text>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Would you like to restore this session into a new workspace?
|
||||
</text>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
|
||||
<For each={options}>
|
||||
{(item) => (
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={item === store.active ? theme.primary : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item)
|
||||
void confirm()
|
||||
}}
|
||||
>
|
||||
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
79
packages/tui/src/component/error-component.tsx
Normal file
79
packages/tui/src/component/error-component.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { useExit } from "../context/exit"
|
||||
|
||||
export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
|
||||
const term = useTerminalDimensions()
|
||||
const exit = useExit()
|
||||
const clipboard = useClipboard()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
void exit()
|
||||
}
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
|
||||
|
||||
// Choose safe fallback colors per mode since theme context may not be available
|
||||
const isLight = props.mode === "light"
|
||||
const colors = {
|
||||
bg: isLight ? "#ffffff" : "#0a0a0a",
|
||||
text: isLight ? "#1a1a1a" : "#eeeeee",
|
||||
muted: isLight ? "#8a8a8a" : "#808080",
|
||||
primary: isLight ? "#3b7dd8" : "#fab283",
|
||||
}
|
||||
|
||||
if (props.error.message) {
|
||||
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
|
||||
}
|
||||
|
||||
if (props.error.stack) {
|
||||
issueURL.searchParams.set(
|
||||
"description",
|
||||
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
|
||||
)
|
||||
}
|
||||
|
||||
issueURL.searchParams.set("opencode-version", InstallationVersion)
|
||||
|
||||
const copyIssueURL = () => {
|
||||
void clipboard.write?.(issueURL.toString()).then(() => {
|
||||
setCopied(true)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1} backgroundColor={colors.bg}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text attributes={TextAttributes.BOLD} fg={colors.text}>
|
||||
Please report an issue.
|
||||
</text>
|
||||
<box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
|
||||
<text attributes={TextAttributes.BOLD} fg={colors.bg}>
|
||||
Copy issue URL (exception info pre-filled)
|
||||
</text>
|
||||
</box>
|
||||
{copied() && <text fg={colors.muted}>Successfully copied</text>}
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<text fg={colors.text}>A fatal error occurred!</text>
|
||||
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Reset TUI</text>
|
||||
</box>
|
||||
<box onMouseUp={() => void exit()} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Exit</text>
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox height={Math.floor(term().height * 0.7)} scrollAcceleration={getScrollAcceleration()}>
|
||||
<text fg={colors.muted}>{props.error.stack}</text>
|
||||
</scrollbox>
|
||||
<text fg={colors.text}>{props.error.message}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
885
packages/tui/src/component/logo.tsx
Normal file
885
packages/tui/src/component/logo.tsx
Normal file
@@ -0,0 +1,885 @@
|
||||
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
|
||||
import { useTheme, tint } from "../context/theme"
|
||||
import { go, logo } from "../logo"
|
||||
|
||||
export type LogoShape = {
|
||||
left: string[]
|
||||
right: string[]
|
||||
}
|
||||
|
||||
type ShimmerConfig = {
|
||||
period: number
|
||||
rings: number
|
||||
sweepFraction: number
|
||||
coreWidth: number
|
||||
coreAmp: number
|
||||
softWidth: number
|
||||
softAmp: number
|
||||
tail: number
|
||||
tailAmp: number
|
||||
haloWidth: number
|
||||
haloOffset: number
|
||||
haloAmp: number
|
||||
breathBase: number
|
||||
noise: number
|
||||
ambientAmp: number
|
||||
ambientCenter: number
|
||||
ambientWidth: number
|
||||
shadowMix: number
|
||||
primaryMix: number
|
||||
originX: number
|
||||
originY: number
|
||||
}
|
||||
|
||||
const shimmerConfig: ShimmerConfig = {
|
||||
period: 4600,
|
||||
rings: 2,
|
||||
sweepFraction: 1,
|
||||
coreWidth: 1.2,
|
||||
coreAmp: 1.9,
|
||||
softWidth: 10,
|
||||
softAmp: 1.6,
|
||||
tail: 5,
|
||||
tailAmp: 0.64,
|
||||
haloWidth: 4.3,
|
||||
haloOffset: 0.6,
|
||||
haloAmp: 0.16,
|
||||
breathBase: 0.04,
|
||||
noise: 0.1,
|
||||
ambientAmp: 0.36,
|
||||
ambientCenter: 0.5,
|
||||
ambientWidth: 0.34,
|
||||
shadowMix: 0.1,
|
||||
primaryMix: 0.3,
|
||||
originX: 4.5,
|
||||
originY: 13.5,
|
||||
}
|
||||
|
||||
// Shadow markers (rendered chars in parens):
|
||||
// _ = full shadow cell (space with bg=shadow)
|
||||
// ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
|
||||
// ~ = shadow top only (▀ with fg=shadow)
|
||||
const GAP = 1
|
||||
const WIDTH = 0.76
|
||||
const GAIN = 2.3
|
||||
const FLASH = 2.15
|
||||
const TRAIL = 0.28
|
||||
const SWELL = 0.24
|
||||
const WIDE = 1.85
|
||||
const DRIFT = 1.45
|
||||
const EXPAND = 1.62
|
||||
const LIFE = 1020
|
||||
const CHARGE = 3000
|
||||
const HOLD = 90
|
||||
const SINK = 40
|
||||
const ARC = 2.2
|
||||
const FORK = 1.2
|
||||
const DIM = 1.04
|
||||
const KICK = 0.86
|
||||
const LAG = 60
|
||||
const SUCK = 0.34
|
||||
const SHIMMER_IN = 60
|
||||
const SHIMMER_OUT = 2.8
|
||||
const TRACE = 0.033
|
||||
const TAIL = 1.8
|
||||
const TRACE_IN = 200
|
||||
const GLOW_OUT = 1600
|
||||
const PEAK = RGBA.fromInts(255, 255, 255)
|
||||
|
||||
type Ring = {
|
||||
x: number
|
||||
y: number
|
||||
at: number
|
||||
force: number
|
||||
kick: number
|
||||
}
|
||||
|
||||
type Hold = {
|
||||
x: number
|
||||
y: number
|
||||
at: number
|
||||
glyph: number | undefined
|
||||
}
|
||||
|
||||
type Release = {
|
||||
x: number
|
||||
y: number
|
||||
at: number
|
||||
glyph: number | undefined
|
||||
level: number
|
||||
rise: number
|
||||
}
|
||||
|
||||
type Glow = {
|
||||
glyph: number
|
||||
at: number
|
||||
force: number
|
||||
}
|
||||
|
||||
type Frame = {
|
||||
t: number
|
||||
list: Ring[]
|
||||
hold: Hold | undefined
|
||||
release: Release | undefined
|
||||
glow: Glow | undefined
|
||||
spark: number
|
||||
}
|
||||
|
||||
const NEAR = [
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 1],
|
||||
[-1, 1],
|
||||
[-1, 0],
|
||||
[-1, -1],
|
||||
[0, -1],
|
||||
[1, -1],
|
||||
] as const
|
||||
|
||||
type Trace = {
|
||||
glyph: number
|
||||
i: number
|
||||
l: number
|
||||
}
|
||||
|
||||
function clamp(n: number) {
|
||||
return Math.max(0, Math.min(1, n))
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number) {
|
||||
return a + (b - a) * clamp(t)
|
||||
}
|
||||
|
||||
function ease(t: number) {
|
||||
const p = clamp(t)
|
||||
return p * p * (3 - 2 * p)
|
||||
}
|
||||
|
||||
function push(t: number) {
|
||||
const p = clamp(t)
|
||||
return ease(p * p)
|
||||
}
|
||||
|
||||
function ramp(t: number, start: number, end: number) {
|
||||
if (end <= start) return ease(t >= end ? 1 : 0)
|
||||
return ease((t - start) / (end - start))
|
||||
}
|
||||
|
||||
function glow(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) {
|
||||
const mid = tint(base, theme.primary, 0.84)
|
||||
const top = tint(theme.primary, PEAK, 0.96)
|
||||
if (n <= 1) return tint(base, mid, Math.min(1, Math.sqrt(Math.max(0, n)) * 1.14))
|
||||
return tint(mid, top, Math.min(1, 1 - Math.exp(-2.4 * (n - 1))))
|
||||
}
|
||||
|
||||
function shade(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) {
|
||||
if (n >= 0) return glow(base, theme, n)
|
||||
return tint(base, theme.background, Math.min(0.82, -n * 0.64))
|
||||
}
|
||||
|
||||
function ghost(n: number, scale: number) {
|
||||
if (n < 0) return n
|
||||
return n * scale
|
||||
}
|
||||
|
||||
function noise(x: number, y: number, t: number) {
|
||||
const n = Math.sin(x * 12.9898 + y * 78.233 + t * 0.043) * 43758.5453
|
||||
return n - Math.floor(n)
|
||||
}
|
||||
|
||||
function lit(char: string) {
|
||||
return char !== " " && char !== "_" && char !== "~" && char !== ","
|
||||
}
|
||||
|
||||
function key(x: number, y: number) {
|
||||
return `${x},${y}`
|
||||
}
|
||||
|
||||
function route(list: Array<{ x: number; y: number }>) {
|
||||
const left = new Map(list.map((item) => [key(item.x, item.y), item]))
|
||||
const path: Array<{ x: number; y: number }> = []
|
||||
let cur = [...left.values()].sort((a, b) => a.y - b.y || a.x - b.x)[0]
|
||||
let dir = { x: 1, y: 0 }
|
||||
|
||||
while (cur) {
|
||||
path.push(cur)
|
||||
left.delete(key(cur.x, cur.y))
|
||||
if (!left.size) return path
|
||||
|
||||
const next = NEAR.map(([dx, dy]) => left.get(key(cur.x + dx, cur.y + dy)))
|
||||
.filter((item): item is { x: number; y: number } => !!item)
|
||||
.sort((a, b) => {
|
||||
const ax = a.x - cur.x
|
||||
const ay = a.y - cur.y
|
||||
const bx = b.x - cur.x
|
||||
const by = b.y - cur.y
|
||||
const adot = ax * dir.x + ay * dir.y
|
||||
const bdot = bx * dir.x + by * dir.y
|
||||
if (adot !== bdot) return bdot - adot
|
||||
return Math.abs(ax) + Math.abs(ay) - (Math.abs(bx) + Math.abs(by))
|
||||
})[0]
|
||||
|
||||
if (!next) {
|
||||
cur = [...left.values()].sort((a, b) => {
|
||||
const da = (a.x - cur.x) ** 2 + (a.y - cur.y) ** 2
|
||||
const db = (b.x - cur.x) ** 2 + (b.y - cur.y) ** 2
|
||||
return da - db
|
||||
})[0]
|
||||
dir = { x: 1, y: 0 }
|
||||
continue
|
||||
}
|
||||
|
||||
dir = { x: next.x - cur.x, y: next.y - cur.y }
|
||||
cur = next
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
function mapGlyphs(full: string[]) {
|
||||
const cells = [] as Array<{ x: number; y: number }>
|
||||
|
||||
for (let y = 0; y < full.length; y++) {
|
||||
for (let x = 0; x < (full[y]?.length ?? 0); x++) {
|
||||
if (lit(full[y]?.[x] ?? " ")) cells.push({ x, y })
|
||||
}
|
||||
}
|
||||
|
||||
const all = new Map(cells.map((item) => [key(item.x, item.y), item]))
|
||||
const seen = new Set<string>()
|
||||
const glyph = new Map<string, number>()
|
||||
const trace = new Map<string, Trace>()
|
||||
const center = new Map<number, { x: number; y: number }>()
|
||||
let id = 0
|
||||
|
||||
for (const item of cells) {
|
||||
const start = key(item.x, item.y)
|
||||
if (seen.has(start)) continue
|
||||
const stack = [item]
|
||||
const part = [] as Array<{ x: number; y: number }>
|
||||
seen.add(start)
|
||||
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!
|
||||
part.push(cur)
|
||||
glyph.set(key(cur.x, cur.y), id)
|
||||
for (const [dx, dy] of NEAR) {
|
||||
const next = all.get(key(cur.x + dx, cur.y + dy))
|
||||
if (!next) continue
|
||||
const mark = key(next.x, next.y)
|
||||
if (seen.has(mark)) continue
|
||||
seen.add(mark)
|
||||
stack.push(next)
|
||||
}
|
||||
}
|
||||
|
||||
const path = route(part)
|
||||
path.forEach((cell, i) => trace.set(key(cell.x, cell.y), { glyph: id, i, l: path.length }))
|
||||
center.set(id, {
|
||||
x: part.reduce((sum, item) => sum + item.x, 0) / part.length + 0.5,
|
||||
y: (part.reduce((sum, item) => sum + item.y, 0) / part.length) * 2 + 1,
|
||||
})
|
||||
id++
|
||||
}
|
||||
|
||||
return { glyph, trace, center }
|
||||
}
|
||||
|
||||
type LogoContext = {
|
||||
LEFT: number
|
||||
FULL: string[]
|
||||
SPAN: number
|
||||
MAP: ReturnType<typeof mapGlyphs>
|
||||
shape: LogoShape
|
||||
}
|
||||
|
||||
function build(shape: LogoShape): LogoContext {
|
||||
const LEFT = shape.left[0]?.length ?? 0
|
||||
const FULL = shape.left.map((line, i) => line + " ".repeat(GAP) + shape.right[i])
|
||||
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
|
||||
return { LEFT, FULL, SPAN, MAP: mapGlyphs(FULL), shape }
|
||||
}
|
||||
|
||||
const DEFAULT = build(logo)
|
||||
const GO = build(go)
|
||||
|
||||
function shimmer(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
return frame.list.reduce((best, item) => {
|
||||
const age = frame.t - item.at
|
||||
if (age < SHIMMER_IN || age > LIFE) return best
|
||||
const dx = x + 0.5 - item.x
|
||||
const dy = y * 2 + 1 - item.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const p = age / LIFE
|
||||
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
|
||||
const lag = r - dist
|
||||
if (lag < 0.18 || lag > SHIMMER_OUT) return best
|
||||
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
|
||||
const wobble = 0.5 + 0.5 * Math.sin(frame.t * 0.035 + x * 0.9 + y * 1.7)
|
||||
const n = band * wobble * (1 - p) ** 1.45
|
||||
if (n > best) return n
|
||||
return best
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function remain(x: number, y: number, item: Release, t: number, ctx: LogoContext) {
|
||||
const age = t - item.at
|
||||
if (age < 0 || age > LIFE) return 0
|
||||
const p = age / LIFE
|
||||
const dx = x + 0.5 - item.x - 0.5
|
||||
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
|
||||
if (dist > r) return 1
|
||||
return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
|
||||
}
|
||||
|
||||
function wave(x: number, y: number, frame: Frame, live: boolean, ctx: LogoContext) {
|
||||
return frame.list.reduce((sum, item) => {
|
||||
const age = frame.t - item.at
|
||||
if (age < 0 || age > LIFE) return sum
|
||||
const p = age / LIFE
|
||||
const dx = x + 0.5 - item.x
|
||||
const dy = y * 2 + 1 - item.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
|
||||
const fade = (1 - p) ** 1.32
|
||||
const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
|
||||
const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
|
||||
const swell = Math.exp(-(((dist - Math.max(0, r - DRIFT)) / WIDE) ** 2)) * SWELL * fade * item.force
|
||||
const trail = dist < r ? Math.exp(-(r - dist) / 2.4) * TRAIL * fade * item.force * lerp(0.92, 1.22, j) : 0
|
||||
const flash = Math.exp(-(dist * dist) / 3.2) * FLASH * item.force * Math.max(0, 1 - age / 140) * lerp(0.95, 1.18, j)
|
||||
const kick = Math.exp(-(dist * dist) / 2) * item.kick * Math.max(0, 1 - age / 100)
|
||||
const suck = Math.exp(-(((dist - 1.25) / 0.75) ** 2)) * item.kick * SUCK * Math.max(0, 1 - age / 110)
|
||||
const wake = live && dist < r ? Math.exp(-(r - dist) / 1.25) * 0.32 * fade : 0
|
||||
return sum + edge + swell + trail + flash + wake - kick - suck
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function field(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
const held = frame.hold
|
||||
const rest = frame.release
|
||||
const item = held ?? rest
|
||||
if (!item) return 0
|
||||
const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise
|
||||
const level = held ? push(rise) : rest!.level
|
||||
const body = rise
|
||||
const storm = level * level
|
||||
const sink = held ? ramp(frame.t - held.at, SINK, CHARGE) : rest!.rise
|
||||
const dx = x + 0.5 - item.x - 0.5
|
||||
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const angle = Math.atan2(dy, dx)
|
||||
const spin = frame.t * lerp(0.008, 0.018, storm)
|
||||
const dim = lerp(0, DIM, sink) * lerp(0.99, 1.01, 0.5 + 0.5 * Math.sin(frame.t * 0.014))
|
||||
const core = Math.exp(-(dist * dist) / Math.max(0.22, lerp(0.22, 3.2, body))) * lerp(0.42, 2.45, body)
|
||||
const shell =
|
||||
Math.exp(-(((dist - lerp(0.16, 2.05, body)) / Math.max(0.18, lerp(0.18, 0.82, body))) ** 2)) * lerp(0.1, 0.95, body)
|
||||
const ember =
|
||||
Math.exp(-(((dist - lerp(0.45, 2.65, body)) / Math.max(0.14, lerp(0.14, 0.62, body))) ** 2)) *
|
||||
lerp(0.02, 0.78, body)
|
||||
const arc = Math.max(0, Math.cos(angle * 3 - spin + frame.spark * 2.2)) ** 8
|
||||
const seam = Math.max(0, Math.cos(angle * 5 + spin * 1.55)) ** 12
|
||||
const ring = Math.exp(-(((dist - lerp(1.05, 3, level)) / 0.48) ** 2)) * arc * lerp(0.03, 0.5 + ARC, storm)
|
||||
const fork = Math.exp(-(((dist - (1.55 + storm * 2.1)) / 0.36) ** 2)) * seam * storm * FORK
|
||||
const spark = Math.max(0, noise(x, y, frame.t) - lerp(0.94, 0.66, storm)) * lerp(0, 5.4, storm)
|
||||
const glitch = spark * Math.exp(-dist / Math.max(1.2, 3.1 - storm))
|
||||
const crack = Math.max(0, Math.cos((dx - dy) * 1.6 + spin * 2.1)) ** 18
|
||||
const lash = crack * Math.exp(-(((dist - (1.95 + storm * 2)) / 0.28) ** 2)) * storm * 1.1
|
||||
const flicker =
|
||||
Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
|
||||
Math.exp(-(dist * dist) / 0.15) *
|
||||
lerp(0.08, 0.42, body)
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
|
||||
return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
|
||||
}
|
||||
|
||||
function pick(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
const held = frame.hold
|
||||
const rest = frame.release
|
||||
const item = held ?? rest
|
||||
if (!item) return 0
|
||||
const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise
|
||||
const dx = x + 0.5 - item.x - 0.5
|
||||
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
|
||||
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
|
||||
}
|
||||
|
||||
function select(x: number, y: number, ctx: LogoContext) {
|
||||
const direct = ctx.MAP.glyph.get(key(x, y))
|
||||
if (direct !== undefined) return direct
|
||||
|
||||
const near = NEAR.map(([dx, dy]) => ctx.MAP.glyph.get(key(x + dx, y + dy))).find(
|
||||
(item): item is number => item !== undefined,
|
||||
)
|
||||
return near
|
||||
}
|
||||
|
||||
function trace(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
const held = frame.hold
|
||||
const rest = frame.release
|
||||
const item = held ?? rest
|
||||
if (!item || item.glyph === undefined) return 0
|
||||
const step = ctx.MAP.trace.get(key(x, y))
|
||||
if (!step || step.glyph !== item.glyph || step.l < 2) return 0
|
||||
const age = frame.t - item.at
|
||||
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
|
||||
const appear = held ? ramp(age, 0, TRACE_IN) : 1
|
||||
const speed = lerp(TRACE * 0.48, TRACE * 0.88, rise)
|
||||
const head = (age * speed) % step.l
|
||||
const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
|
||||
const tail = (head - TAIL + step.l) % step.l
|
||||
const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
|
||||
const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
|
||||
const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
|
||||
const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
|
||||
return (core + glow + trail) * appear * fade
|
||||
}
|
||||
|
||||
function idle(
|
||||
x: number,
|
||||
pixelY: number,
|
||||
frame: Frame,
|
||||
ctx: LogoContext,
|
||||
state: IdleState,
|
||||
): { glow: number; peak: number; primary: number } {
|
||||
const cfg = state.cfg
|
||||
const dx = x + 0.5 - cfg.originX
|
||||
const dy = pixelY - cfg.originY
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const angle = Math.atan2(dy, dx)
|
||||
const wob1 = noise(x * 0.32, pixelY * 0.25, frame.t * 0.0005) - 0.5
|
||||
const wob2 = noise(x * 0.12, pixelY * 0.08, frame.t * 0.00022) - 0.5
|
||||
const ripple = Math.sin(angle * 3 + frame.t * 0.0012) * 0.3
|
||||
const jitter = (wob1 * 0.55 + wob2 * 0.32 + ripple * 0.18) * cfg.noise
|
||||
const traveled = dist + jitter
|
||||
let glow = 0
|
||||
let peak = 0
|
||||
let halo = 0
|
||||
let primary = 0
|
||||
let ambient = 0
|
||||
for (const active of state.active) {
|
||||
const head = active.head
|
||||
const eased = active.eased
|
||||
const delta = traveled - head
|
||||
// Use shallower exponent (1.6 vs 2) for softer edges on the Gaussians
|
||||
// so adjacent pixels have smaller brightness deltas
|
||||
const core = Math.exp(-(Math.abs(delta / cfg.coreWidth) ** 1.8))
|
||||
const soft = Math.exp(-(Math.abs(delta / cfg.softWidth) ** 1.6))
|
||||
const tailRange = cfg.tail * 2.6
|
||||
const tail = delta < 0 && delta > -tailRange ? (1 + delta / tailRange) ** 2.6 : 0
|
||||
const haloDelta = delta + cfg.haloOffset
|
||||
const haloBand = Math.exp(-(Math.abs(haloDelta / cfg.haloWidth) ** 1.6))
|
||||
glow += (soft * cfg.softAmp + tail * cfg.tailAmp) * eased
|
||||
peak += core * cfg.coreAmp * eased
|
||||
halo += haloBand * cfg.haloAmp * eased
|
||||
// Primary-tinted fringe follows the halo (which trails behind the core) and the tail
|
||||
primary += (haloBand + tail * 0.6) * eased
|
||||
ambient += active.ambient
|
||||
}
|
||||
ambient /= state.rings
|
||||
return {
|
||||
glow: glow / state.rings,
|
||||
peak: cfg.breathBase + ambient + (peak + halo) / state.rings,
|
||||
primary: (primary / state.rings) * cfg.primaryMix,
|
||||
}
|
||||
}
|
||||
|
||||
function bloom(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
const item = frame.glow
|
||||
if (!item) return 0
|
||||
const glyph = ctx.MAP.glyph.get(key(x, y))
|
||||
if (glyph !== item.glyph) return 0
|
||||
const age = frame.t - item.at
|
||||
if (age < 0 || age > GLOW_OUT) return 0
|
||||
const p = age / GLOW_OUT
|
||||
const flash = (1 - p) ** 2
|
||||
const dx = x + 0.5 - ctx.MAP.center.get(item.glyph)!.x
|
||||
const dy = y * 2 + 1 - ctx.MAP.center.get(item.glyph)!.y
|
||||
const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
|
||||
return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
|
||||
}
|
||||
|
||||
type IdleState = {
|
||||
cfg: ShimmerConfig
|
||||
reach: number
|
||||
rings: number
|
||||
active: Array<{
|
||||
head: number
|
||||
eased: number
|
||||
ambient: number
|
||||
}>
|
||||
}
|
||||
|
||||
function buildIdleState(t: number, ctx: LogoContext): IdleState {
|
||||
const cfg = shimmerConfig
|
||||
const w = ctx.FULL[0]?.length ?? 1
|
||||
const h = ctx.FULL.length * 2
|
||||
const corners: [number, number][] = [
|
||||
[0, 0],
|
||||
[w, 0],
|
||||
[0, h],
|
||||
[w, h],
|
||||
]
|
||||
let maxCorner = 0
|
||||
for (const [cx, cy] of corners) {
|
||||
const d = Math.hypot(cx - cfg.originX, cy - cfg.originY)
|
||||
if (d > maxCorner) maxCorner = d
|
||||
}
|
||||
const reach = maxCorner + cfg.tail * 2
|
||||
const rings = Math.max(1, Math.floor(cfg.rings))
|
||||
const active = [] as IdleState["active"]
|
||||
for (let i = 0; i < rings; i++) {
|
||||
const offset = i / rings
|
||||
const cyclePhase = (t / cfg.period + offset) % 1
|
||||
if (cyclePhase >= cfg.sweepFraction) continue
|
||||
const phase = cyclePhase / cfg.sweepFraction
|
||||
const envelope = Math.sin(phase * Math.PI)
|
||||
const eased = envelope * envelope * (3 - 2 * envelope)
|
||||
const d = (phase - cfg.ambientCenter) / cfg.ambientWidth
|
||||
active.push({
|
||||
head: phase * reach,
|
||||
eased,
|
||||
ambient: Math.abs(d) < 1 ? (1 - d * d) ** 2 * cfg.ambientAmp : 0,
|
||||
})
|
||||
}
|
||||
return { cfg, reach, rings, active }
|
||||
}
|
||||
|
||||
export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) {
|
||||
const ctx = props.shape ? build(props.shape) : DEFAULT
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const [rings, setRings] = createSignal<Ring[]>([])
|
||||
const [hold, setHold] = createSignal<Hold>()
|
||||
const [release, setRelease] = createSignal<Release>()
|
||||
const [glow, setGlow] = createSignal<Glow>()
|
||||
const [now, setNow] = createSignal(0)
|
||||
let box: BoxRenderable | undefined
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const stop = () => {
|
||||
if (!timer) return
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const t = performance.now()
|
||||
setNow(t)
|
||||
const item = hold()
|
||||
if (item && t - item.at >= CHARGE) {
|
||||
burst(item.x, item.y)
|
||||
}
|
||||
let live = false
|
||||
setRings((list) => {
|
||||
const next = list.filter((item) => t - item.at < LIFE)
|
||||
live = next.length > 0
|
||||
return next
|
||||
})
|
||||
const flash = glow()
|
||||
if (flash && t - flash.at >= GLOW_OUT) {
|
||||
setGlow(undefined)
|
||||
}
|
||||
if (!live) setRelease(undefined)
|
||||
if (live || hold() || release() || glow()) return
|
||||
if (props.idle) return
|
||||
stop()
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (timer) return
|
||||
timer = setInterval(tick, 16)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!props.idle) return
|
||||
setNow(performance.now())
|
||||
start()
|
||||
})
|
||||
|
||||
const hit = (x: number, y: number) => {
|
||||
const char = ctx.FULL[y]?.[x]
|
||||
return char !== undefined && char !== " "
|
||||
}
|
||||
|
||||
const press = (x: number, y: number, t: number) => {
|
||||
const last = hold()
|
||||
if (last) burst(last.x, last.y)
|
||||
setNow(t)
|
||||
if (!last) setRelease(undefined)
|
||||
setHold({ x, y, at: t, glyph: select(x, y, ctx) })
|
||||
start()
|
||||
}
|
||||
|
||||
const burst = (x: number, y: number) => {
|
||||
const item = hold()
|
||||
if (!item) return
|
||||
const t = performance.now()
|
||||
const age = t - item.at
|
||||
const rise = ramp(age, HOLD, CHARGE)
|
||||
const level = push(rise)
|
||||
setHold(undefined)
|
||||
setRelease({ x, y, at: t, glyph: item.glyph, level, rise })
|
||||
if (item.glyph !== undefined) {
|
||||
setGlow({ glyph: item.glyph, at: t, force: lerp(0.18, 1.5, rise * level) })
|
||||
}
|
||||
setRings((list) => [
|
||||
...list,
|
||||
{
|
||||
x: x + 0.5,
|
||||
y: y * 2 + 1,
|
||||
at: t,
|
||||
force: lerp(0.82, 2.55, level),
|
||||
kick: lerp(0.32, 0.32 + KICK, level),
|
||||
},
|
||||
])
|
||||
setNow(t)
|
||||
start()
|
||||
}
|
||||
|
||||
const frame = createMemo(() => {
|
||||
const t = now()
|
||||
const item = hold()
|
||||
return {
|
||||
t,
|
||||
list: rings(),
|
||||
hold: item,
|
||||
release: release(),
|
||||
glow: glow(),
|
||||
spark: item ? noise(item.x, item.y, t) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
const dusk = createMemo(() => {
|
||||
const base = frame()
|
||||
const t = base.t - LAG
|
||||
const item = base.hold
|
||||
return {
|
||||
t,
|
||||
list: base.list,
|
||||
hold: item,
|
||||
release: base.release,
|
||||
glow: base.glow,
|
||||
spark: item ? noise(item.x, item.y, t) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined))
|
||||
const useSubpixelBlocks = () => renderer.capabilities?.rgb === true
|
||||
|
||||
const renderLine = (
|
||||
line: string,
|
||||
y: number,
|
||||
ink: RGBA,
|
||||
bold: boolean,
|
||||
off: number,
|
||||
frame: Frame,
|
||||
dusk: Frame,
|
||||
state: IdleState | undefined,
|
||||
): JSX.Element[] => {
|
||||
const shadow = tint(theme.background, ink, 0.25)
|
||||
const attrs = bold ? TextAttributes.BOLD : undefined
|
||||
|
||||
return Array.from(line).map((char, i) => {
|
||||
if (char === " ") {
|
||||
return (
|
||||
<text fg={ink} attributes={attrs} selectable={false}>
|
||||
{char}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
const h = field(off + i, y, frame, ctx)
|
||||
const charLit = lit(char)
|
||||
// Sub-pixel sampling: cells are 2 pixels tall. Sample at top (y*2) and bottom (y*2+1) pixel rows.
|
||||
const pulseTop = state ? idle(off + i, y * 2, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
|
||||
const pulseBot = state ? idle(off + i, y * 2 + 1, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
|
||||
const peakMixTop = charLit ? Math.min(1, pulseTop.peak) : 0
|
||||
const peakMixBot = charLit ? Math.min(1, pulseBot.peak) : 0
|
||||
const primaryMixTop = charLit ? Math.min(1, pulseTop.primary) : 0
|
||||
const primaryMixBot = charLit ? Math.min(1, pulseBot.primary) : 0
|
||||
// Layer primary tint first, then white peak on top — so the halo/tail pulls toward primary,
|
||||
// while the bright core stays pure white
|
||||
const inkTopTint = primaryMixTop > 0 ? tint(ink, theme.primary, primaryMixTop) : ink
|
||||
const inkBotTint = primaryMixBot > 0 ? tint(ink, theme.primary, primaryMixBot) : ink
|
||||
const inkTop = peakMixTop > 0 ? tint(inkTopTint, PEAK, peakMixTop) : inkTopTint
|
||||
const inkBot = peakMixBot > 0 ? tint(inkBotTint, PEAK, peakMixBot) : inkBotTint
|
||||
// For the non-peak-aware brightness channels, use the average of top/bot
|
||||
const pulse = {
|
||||
glow: (pulseTop.glow + pulseBot.glow) / 2,
|
||||
peak: (pulseTop.peak + pulseBot.peak) / 2,
|
||||
primary: (pulseTop.primary + pulseBot.primary) / 2,
|
||||
}
|
||||
const peakMix = charLit ? Math.min(1, pulse.peak) : 0
|
||||
const primaryMix = charLit ? Math.min(1, pulse.primary) : 0
|
||||
const inkPrimary = primaryMix > 0 ? tint(ink, theme.primary, primaryMix) : ink
|
||||
const inkTinted = peakMix > 0 ? tint(inkPrimary, PEAK, peakMix) : inkPrimary
|
||||
const shadowMixCfg = state?.cfg.shadowMix ?? shimmerConfig.shadowMix
|
||||
const shadowMixTop = Math.min(1, pulseTop.peak * shadowMixCfg)
|
||||
const shadowMixBot = Math.min(1, pulseBot.peak * shadowMixCfg)
|
||||
const shadowTop = shadowMixTop > 0 ? tint(shadow, PEAK, shadowMixTop) : shadow
|
||||
const shadowBot = shadowMixBot > 0 ? tint(shadow, PEAK, shadowMixBot) : shadow
|
||||
const shadowMix = Math.min(1, pulse.peak * shadowMixCfg)
|
||||
const shadowTinted = shadowMix > 0 ? tint(shadow, PEAK, shadowMix) : shadow
|
||||
const n = wave(off + i, y, frame, charLit, ctx) + h
|
||||
const s = wave(off + i, y, dusk, false, ctx) + h
|
||||
const p = charLit ? pick(off + i, y, frame, ctx) : 0
|
||||
const e = charLit ? trace(off + i, y, frame, ctx) : 0
|
||||
const b = charLit ? bloom(off + i, y, frame, ctx) : 0
|
||||
const q = shimmer(off + i, y, frame, ctx)
|
||||
|
||||
if (char === "_") {
|
||||
return (
|
||||
<text
|
||||
fg={shade(inkTinted, theme, s * 0.08)}
|
||||
bg={shade(shadowTinted, theme, ghost(s, 0.24) + ghost(q, 0.06))}
|
||||
attributes={attrs}
|
||||
selectable={false}
|
||||
>
|
||||
{" "}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
if (char === "^") {
|
||||
return (
|
||||
<text
|
||||
fg={shade(inkTop, theme, n + p + e + b)}
|
||||
bg={shade(shadowBot, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
|
||||
attributes={attrs}
|
||||
selectable={false}
|
||||
>
|
||||
▀
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
if (char === "~") {
|
||||
return (
|
||||
<text fg={shade(shadowTop, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
||||
▀
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
if (char === ",") {
|
||||
return (
|
||||
<text fg={shade(shadowBot, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
||||
▄
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
// Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values
|
||||
if (char === "█" && useSubpixelBlocks()) {
|
||||
return (
|
||||
<text
|
||||
fg={shade(inkTop, theme, n + p + e + b)}
|
||||
bg={shade(inkBot, theme, n + p + e + b)}
|
||||
attributes={attrs}
|
||||
selectable={false}
|
||||
>
|
||||
▀
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
// ▀ top-half-lit: fg uses top-pixel sample, bg stays transparent/panel
|
||||
if (char === "▀") {
|
||||
return (
|
||||
<text fg={shade(inkTop, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||
▀
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
// ▄ bottom-half-lit: fg uses bottom-pixel sample
|
||||
if (char === "▄") {
|
||||
return (
|
||||
<text fg={shade(inkBot, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||
▄
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<text fg={shade(inkTinted, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||
{char}
|
||||
</text>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const mouse = (evt: MouseEvent) => {
|
||||
if (!box) return
|
||||
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
|
||||
const x = evt.x - box.x
|
||||
const y = evt.y - box.y
|
||||
if (!hit(x, y)) return
|
||||
if (evt.type === "drag" && hold()) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
const t = performance.now()
|
||||
press(x, y, t)
|
||||
return
|
||||
}
|
||||
|
||||
if (!hold()) return
|
||||
if (evt.type === "up") {
|
||||
const item = hold()
|
||||
if (!item) return
|
||||
burst(item.x, item.y)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<box ref={(item: BoxRenderable) => (box = item)}>
|
||||
<box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
width={ctx.FULL[0]?.length ?? 0}
|
||||
height={ctx.FULL.length}
|
||||
zIndex={1}
|
||||
onMouse={mouse}
|
||||
/>
|
||||
<For each={ctx.shape.left}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="row">
|
||||
{renderLine(line, index(), props.ink ?? theme.textMuted, !!props.ink, 0, frame(), dusk(), idleState())}
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
{renderLine(
|
||||
ctx.shape.right[index()],
|
||||
index(),
|
||||
props.ink ?? theme.text,
|
||||
true,
|
||||
ctx.LEFT + GAP,
|
||||
frame(),
|
||||
dusk(),
|
||||
idleState(),
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function GoLogo() {
|
||||
const { theme } = useTheme()
|
||||
const base = tint(theme.background, theme.text, 0.62)
|
||||
return <Logo shape={go} ink={base} idle />
|
||||
}
|
||||
14
packages/tui/src/component/plugin-route-missing.tsx
Normal file
14
packages/tui/src/component/plugin-route-missing.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useTheme } from "../context/theme"
|
||||
|
||||
export function PluginRouteMissing(props: { id: string; onHome: () => void }) {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
|
||||
<text fg={theme.warning}>Unknown plugin route: {props.id}</text>
|
||||
<box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
|
||||
<text fg={theme.text}>go home</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
768
packages/tui/src/component/prompt/autocomplete.tsx
Normal file
768
packages/tui/src/component/prompt/autocomplete.tsx
Normal file
@@ -0,0 +1,768 @@
|
||||
import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { pathToFileURL } from "bun"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import path from "path"
|
||||
import { firstBy } from "remeda"
|
||||
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useEditorContext } from "../../context/editor"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useData } from "../../context/data"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useTuiConfig } from "../../config"
|
||||
import { useTheme, selectedForeground } from "../../context/theme"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Locale } from "../../util/locale"
|
||||
import type { PromptInfo } from "../../prompt/history"
|
||||
import { useFrecency } from "../../prompt/frecency"
|
||||
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
|
||||
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
||||
|
||||
function removeLineRange(input: string) {
|
||||
const hashIndex = input.lastIndexOf("#")
|
||||
return hashIndex !== -1 ? input.substring(0, hashIndex) : input
|
||||
}
|
||||
|
||||
function extractLineRange(input: string) {
|
||||
const hashIndex = input.lastIndexOf("#")
|
||||
if (hashIndex === -1) {
|
||||
return { baseQuery: input }
|
||||
}
|
||||
|
||||
const baseName = input.substring(0, hashIndex)
|
||||
const linePart = input.substring(hashIndex + 1)
|
||||
const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/)
|
||||
|
||||
if (!lineMatch) {
|
||||
return { baseQuery: baseName }
|
||||
}
|
||||
|
||||
const startLine = Number(lineMatch[1])
|
||||
const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined
|
||||
|
||||
return {
|
||||
lineRange: {
|
||||
baseName,
|
||||
startLine,
|
||||
endLine,
|
||||
},
|
||||
baseQuery: baseName,
|
||||
}
|
||||
}
|
||||
|
||||
export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
visible: false | "@" | "/"
|
||||
}
|
||||
|
||||
export type AutocompleteOption = {
|
||||
display: string
|
||||
value?: string
|
||||
aliases?: string[]
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
isDirectory?: boolean
|
||||
onSelect?: () => void
|
||||
path?: string
|
||||
}
|
||||
|
||||
export function Autocomplete(props: {
|
||||
value: string
|
||||
sessionID?: string
|
||||
setPrompt: (input: (prompt: PromptInfo) => void) => void
|
||||
setExtmark: (partIndex: number, extmarkId: number) => void
|
||||
anchor: () => BoxRenderable
|
||||
input: () => TextareaRenderable
|
||||
ref: (ref: AutocompleteRef) => void
|
||||
fileStyleId: number
|
||||
agentStyleId: number
|
||||
promptPartTypeId: () => number
|
||||
}) {
|
||||
const editor = useEditorContext()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const project = useProject()
|
||||
const slashes = useCommandSlashes()
|
||||
const modeStack = useOpencodeModeStack()
|
||||
const { theme } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const frecency = useFrecency()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const paths = useTuiPaths()
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
selected: 0,
|
||||
visible: false as AutocompleteRef["visible"],
|
||||
input: "keyboard" as "keyboard" | "mouse",
|
||||
})
|
||||
|
||||
const [positionTick, setPositionTick] = createSignal(0)
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.visible) return
|
||||
const popMode = modeStack.push("autocomplete")
|
||||
onCleanup(popMode)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (store.visible) {
|
||||
let lastPos = { x: 0, y: 0, width: 0 }
|
||||
const interval = setInterval(() => {
|
||||
const anchor = props.anchor()
|
||||
if (anchor.x !== lastPos.x || anchor.y !== lastPos.y || anchor.width !== lastPos.width) {
|
||||
lastPos = { x: anchor.x, y: anchor.y, width: anchor.width }
|
||||
setPositionTick((t) => t + 1)
|
||||
}
|
||||
}, 50)
|
||||
|
||||
onCleanup(() => clearInterval(interval))
|
||||
}
|
||||
})
|
||||
|
||||
const position = createMemo(() => {
|
||||
if (!store.visible) return { x: 0, y: 0, width: 0 }
|
||||
dimensions()
|
||||
positionTick()
|
||||
const anchor = props.anchor()
|
||||
const parent = anchor.parent
|
||||
const parentX = parent?.x ?? 0
|
||||
const parentY = parent?.y ?? 0
|
||||
|
||||
return {
|
||||
x: anchor.x - parentX,
|
||||
y: anchor.y - parentY,
|
||||
width: anchor.width,
|
||||
}
|
||||
})
|
||||
|
||||
const filter = createMemo(() => {
|
||||
if (!store.visible) return
|
||||
// Track props.value to make memo reactive to text changes
|
||||
props.value // <- there surely is a better way to do this, like making .input() reactive
|
||||
|
||||
return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
|
||||
})
|
||||
|
||||
// filter() reads reactive props.value plus non-reactive cursor/text state.
|
||||
// On keypress those can be briefly out of sync, so filter() may return an empty/partial string.
|
||||
// Copy it into search in an effect because effects run after reactive updates have been rendered and painted
|
||||
// so the input has settled and all consumers read the same stable value.
|
||||
const [search, setSearch] = createSignal("")
|
||||
createEffect(() => {
|
||||
const next = filter()
|
||||
setSearch(next ? next : "")
|
||||
})
|
||||
|
||||
// When the filter changes due to how TUI works, the mousemove might still be triggered
|
||||
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so
|
||||
// that the mouseover event doesn't trigger when filtering.
|
||||
createEffect(() => {
|
||||
filter()
|
||||
setStore("input", "keyboard")
|
||||
})
|
||||
|
||||
function insertPart(text: string, part: PromptInfo["parts"][number]) {
|
||||
const input = props.input()
|
||||
const currentCursorOffset = input.cursorOffset
|
||||
|
||||
const charAfterCursor = displayCharAt(props.value, currentCursorOffset)
|
||||
const needsSpace = charAfterCursor !== " "
|
||||
const append = "@" + text + (needsSpace ? " " : "")
|
||||
|
||||
input.cursorOffset = store.index
|
||||
const startCursor = input.logicalCursor
|
||||
input.cursorOffset = currentCursorOffset
|
||||
const endCursor = input.logicalCursor
|
||||
|
||||
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
|
||||
input.insertText(append)
|
||||
|
||||
const virtualText = "@" + text
|
||||
const extmarkStart = store.index
|
||||
const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
|
||||
|
||||
const styleId = part.type === "file" ? props.fileStyleId : part.type === "agent" ? props.agentStyleId : undefined
|
||||
|
||||
const extmarkId = input.extmarks.create({
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
virtual: true,
|
||||
styleId,
|
||||
typeId: props.promptPartTypeId(),
|
||||
})
|
||||
|
||||
props.setPrompt((draft) => {
|
||||
if (part.type === "file") {
|
||||
const existingIndex = draft.parts.findIndex((p) => p.type === "file" && "url" in p && p.url === part.url)
|
||||
if (existingIndex !== -1) {
|
||||
const existing = draft.parts[existingIndex]
|
||||
if (
|
||||
part.source?.text &&
|
||||
existing &&
|
||||
"source" in existing &&
|
||||
existing.source &&
|
||||
"text" in existing.source &&
|
||||
existing.source.text
|
||||
) {
|
||||
existing.source.text.start = extmarkStart
|
||||
existing.source.text.end = extmarkEnd
|
||||
existing.source.text.value = virtualText
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (part.type === "file" && part.source?.text) {
|
||||
part.source.text.start = extmarkStart
|
||||
part.source.text.end = extmarkEnd
|
||||
part.source.text.value = virtualText
|
||||
} else if (part.type === "agent" && part.source) {
|
||||
part.source.start = extmarkStart
|
||||
part.source.end = extmarkEnd
|
||||
part.source.value = virtualText
|
||||
}
|
||||
const partIndex = draft.parts.length
|
||||
draft.parts.push(part)
|
||||
props.setExtmark(partIndex, extmarkId)
|
||||
})
|
||||
|
||||
if (part.type === "file" && part.source && part.source.type === "file") {
|
||||
frecency.updateFrecency(part.source.path)
|
||||
}
|
||||
}
|
||||
|
||||
function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) {
|
||||
const baseDir = (sync.path.directory || paths.cwd).replace(/\/+$/, "")
|
||||
const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item)
|
||||
const urlObj = pathToFileURL(fullPath)
|
||||
const filename =
|
||||
lineRange && !item.endsWith("/")
|
||||
? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
|
||||
: item
|
||||
|
||||
if (lineRange && !item.endsWith("/")) {
|
||||
urlObj.searchParams.set("start", String(lineRange.startLine))
|
||||
if (lineRange.endLine !== undefined) {
|
||||
urlObj.searchParams.set("end", String(lineRange.endLine))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filename,
|
||||
url: urlObj.href,
|
||||
part: {
|
||||
type: "file" as const,
|
||||
mime: "text/plain",
|
||||
filename,
|
||||
url: urlObj.href,
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
path: item,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const references = createMemo(() => data.location.reference.list() ?? [])
|
||||
|
||||
const referenceMatch = createMemo(() => {
|
||||
if (!store.visible || store.visible === "/") return
|
||||
const { baseQuery } = extractLineRange(search())
|
||||
const slash = baseQuery.indexOf("/")
|
||||
const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
|
||||
return references().find((item) => !item.hidden && item.name === alias)
|
||||
})
|
||||
|
||||
function normalizeMentionPath(filePath: string) {
|
||||
const baseDir = sync.path.directory || paths.cwd
|
||||
const absolute = path.resolve(filePath)
|
||||
const relative = path.relative(baseDir, absolute)
|
||||
|
||||
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
|
||||
return relative.split(path.sep).join("/")
|
||||
}
|
||||
|
||||
return absolute.split(path.sep).join("/")
|
||||
}
|
||||
|
||||
function insertFileMention(input: { filePath: string; lineStart: number; lineEnd: number }) {
|
||||
const item = normalizeMentionPath(input.filePath)
|
||||
const lineRange = {
|
||||
startLine: input.lineStart,
|
||||
endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined,
|
||||
}
|
||||
const { filename, part } = createFilePart(item, lineRange)
|
||||
const index = store.visible === "@" ? store.index : props.input().cursorOffset
|
||||
|
||||
setStore("visible", false)
|
||||
setStore("index", index)
|
||||
insertPart(filename, part)
|
||||
}
|
||||
|
||||
const [files] = createResource(
|
||||
() => search(),
|
||||
async (query) => {
|
||||
if (!store.visible || store.visible === "/") return []
|
||||
if (referenceMatch()) return []
|
||||
const { lineRange, baseQuery } = extractLineRange(query ?? "")
|
||||
|
||||
// Get files from SDK
|
||||
const result = await sdk.client.v2.fs.find({
|
||||
query: baseQuery,
|
||||
limit: "20",
|
||||
location: { workspace: project.workspace.current() },
|
||||
})
|
||||
|
||||
const options: AutocompleteOption[] = []
|
||||
|
||||
// Add file options. Trust the order returned by fff (frecency, fuzzy
|
||||
// score, filename bonus, etc. are already factored in).
|
||||
if (!result.error && result.data) {
|
||||
const width = props.anchor().width - 4
|
||||
options.push(
|
||||
...result.data.data.map((item): AutocompleteOption => {
|
||||
const { filename, url, part } = createFilePart(item.path, lineRange)
|
||||
return {
|
||||
display: Locale.truncateMiddle(filename, width),
|
||||
value: filename,
|
||||
isDirectory: item.type === "directory",
|
||||
path: item.path,
|
||||
onSelect: () => {
|
||||
insertPart(filename, part)
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
},
|
||||
{
|
||||
initialValue: [],
|
||||
},
|
||||
)
|
||||
|
||||
const mcpResources = createMemo(() => {
|
||||
if (!store.visible || store.visible === "/") return []
|
||||
|
||||
const options: AutocompleteOption[] = []
|
||||
const width = props.anchor().width - 4
|
||||
|
||||
for (const res of Object.values(sync.data.mcp_resource)) {
|
||||
const text = `${res.name} (${res.uri})`
|
||||
options.push({
|
||||
display: Locale.truncateMiddle(text, width),
|
||||
value: text,
|
||||
description: res.description,
|
||||
onSelect: () => {
|
||||
insertPart(res.name, {
|
||||
type: "file",
|
||||
mime: res.mimeType ?? "text/plain",
|
||||
filename: res.name,
|
||||
url: res.uri,
|
||||
source: {
|
||||
type: "resource",
|
||||
text: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
clientName: res.client,
|
||||
uri: res.uri,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const agents = createMemo(() => {
|
||||
return sync.data.agent
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
.map(
|
||||
(agent): AutocompleteOption => ({
|
||||
display: "@" + agent.name,
|
||||
onSelect: () => {
|
||||
insertPart(agent.name, {
|
||||
type: "agent",
|
||||
name: agent.name,
|
||||
source: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const referenceAliases = createMemo(() =>
|
||||
references()
|
||||
.filter((reference) => !reference.hidden)
|
||||
.map(
|
||||
(reference): AutocompleteOption => ({
|
||||
display: "@" + reference.name,
|
||||
description: ` ${reference.source.type === "git" ? reference.source.repository : reference.source.path}`,
|
||||
onSelect: () => {
|
||||
insertPart(reference.name, {
|
||||
type: "file",
|
||||
mime: "application/x-directory",
|
||||
filename: reference.name,
|
||||
url: pathToFileURL(reference.path).href,
|
||||
source: {
|
||||
type: "file",
|
||||
text: { start: 0, end: 0, value: "" },
|
||||
path: reference.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const commands = createMemo((): AutocompleteOption[] => {
|
||||
const results: AutocompleteOption[] = [...slashes()]
|
||||
|
||||
for (const serverCommand of sync.data.command) {
|
||||
if (serverCommand.source === "skill") continue
|
||||
const label = serverCommand.source === "mcp" ? ":mcp" : ""
|
||||
results.push({
|
||||
display: "/" + serverCommand.name + label,
|
||||
description: serverCommand.description,
|
||||
onSelect: () => {
|
||||
const newText = "/" + serverCommand.name + " "
|
||||
const cursor = props.input().logicalCursor
|
||||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||
props.input().insertText(newText)
|
||||
props.input().cursorOffset = Bun.stringWidth(newText)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
results.sort((a, b) => a.display.localeCompare(b.display))
|
||||
|
||||
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
|
||||
if (!max) return results
|
||||
return results.map((item) => ({
|
||||
...item,
|
||||
display: item.display.padEnd(max + 2),
|
||||
}))
|
||||
})
|
||||
|
||||
const options = createMemo((prev: AutocompleteOption[] | undefined) => {
|
||||
const filesValue = files()
|
||||
const referenceMatchValue = referenceMatch()
|
||||
const agentsValue = agents()
|
||||
const referenceAliasesValue = referenceAliases()
|
||||
const commandsValue = commands()
|
||||
const searchValue = search()
|
||||
|
||||
if (store.visible === "@" && referenceMatchValue) {
|
||||
return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
|
||||
}
|
||||
|
||||
// Files come from fff already fuzzy ranked and filtered
|
||||
// it shouldn't be additionally sorted by fuzzysort as it will loose the results
|
||||
const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : []
|
||||
const nonFileOptions: AutocompleteOption[] =
|
||||
store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue]
|
||||
|
||||
if (!searchValue) {
|
||||
return [...nonFileOptions, ...fileOptions]
|
||||
}
|
||||
|
||||
if (files.loading && prev && prev.length > 0) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const fuzziedNonFiles = fuzzysort
|
||||
.go(removeLineRange(searchValue), nonFileOptions, {
|
||||
keys: [
|
||||
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
|
||||
"description",
|
||||
(obj) => obj.aliases?.join(" ") ?? "",
|
||||
],
|
||||
limit: 10,
|
||||
scoreFn: (objResults) => {
|
||||
const displayResult = objResults[0]
|
||||
let score = objResults.score
|
||||
if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) {
|
||||
score *= 2
|
||||
}
|
||||
const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0
|
||||
return score * (1 + frecencyScore)
|
||||
},
|
||||
})
|
||||
.map((arr) => arr.obj)
|
||||
|
||||
return [...fuzziedNonFiles, ...fileOptions].slice(0, 10)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
filter()
|
||||
setStore("selected", 0)
|
||||
})
|
||||
|
||||
function move(direction: -1 | 1) {
|
||||
if (!store.visible) return
|
||||
if (!options().length) return
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = options().length - 1
|
||||
if (next >= options().length) next = 0
|
||||
moveTo(next)
|
||||
}
|
||||
|
||||
function moveTo(next: number) {
|
||||
setStore("selected", next)
|
||||
if (!scroll) return
|
||||
const viewportHeight = Math.min(height(), options().length)
|
||||
const scrollBottom = scroll.scrollTop + viewportHeight
|
||||
if (next < scroll.scrollTop) {
|
||||
scroll.scrollBy(next - scroll.scrollTop)
|
||||
} else if (next + 1 > scrollBottom) {
|
||||
scroll.scrollBy(next + 1 - scrollBottom)
|
||||
}
|
||||
}
|
||||
|
||||
function select() {
|
||||
const selected = options()[store.selected]
|
||||
if (!selected) return
|
||||
hide()
|
||||
selected.onSelect?.()
|
||||
}
|
||||
|
||||
function expandDirectory() {
|
||||
const selected = options()[store.selected]
|
||||
if (!selected) return
|
||||
|
||||
const input = props.input()
|
||||
const currentCursorOffset = input.cursorOffset
|
||||
|
||||
const displayText = (selected.value ?? selected.display).trimEnd()
|
||||
const path = displayText.startsWith("@") ? displayText.slice(1) : displayText
|
||||
|
||||
input.cursorOffset = store.index
|
||||
const startCursor = input.logicalCursor
|
||||
input.cursorOffset = currentCursorOffset
|
||||
const endCursor = input.logicalCursor
|
||||
|
||||
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
|
||||
input.insertText("@" + path + "/")
|
||||
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
useBindings(() => ({
|
||||
target: props.input,
|
||||
enabled: () => Boolean(store.visible),
|
||||
commands: [
|
||||
{
|
||||
name: "prompt.autocomplete.prev",
|
||||
title: "Previous autocomplete item",
|
||||
category: "Autocomplete",
|
||||
run() {
|
||||
setStore("input", "keyboard")
|
||||
move(-1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt.autocomplete.next",
|
||||
title: "Next autocomplete item",
|
||||
category: "Autocomplete",
|
||||
run() {
|
||||
setStore("input", "keyboard")
|
||||
move(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt.autocomplete.hide",
|
||||
title: "Hide autocomplete",
|
||||
category: "Autocomplete",
|
||||
run() {
|
||||
hide()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt.autocomplete.select",
|
||||
title: "Select autocomplete item",
|
||||
category: "Autocomplete",
|
||||
run() {
|
||||
select()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt.autocomplete.complete",
|
||||
title: "Complete autocomplete item",
|
||||
category: "Autocomplete",
|
||||
run() {
|
||||
const selected = options()[store.selected]
|
||||
if (selected?.isDirectory) {
|
||||
expandDirectory()
|
||||
return
|
||||
}
|
||||
|
||||
select()
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: tuiConfig.keybinds.gather("prompt.autocomplete", [
|
||||
"prompt.autocomplete.prev",
|
||||
"prompt.autocomplete.next",
|
||||
"prompt.autocomplete.hide",
|
||||
"prompt.autocomplete.select",
|
||||
"prompt.autocomplete.complete",
|
||||
]),
|
||||
}))
|
||||
|
||||
function show(mode: "@" | "/") {
|
||||
setStore({
|
||||
visible: mode,
|
||||
index: props.input().cursorOffset,
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
const text = props.input().plainText
|
||||
if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
|
||||
const cursor = props.input().logicalCursor
|
||||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||
// Sync the prompt store immediately since onContentChange is async
|
||||
props.setPrompt((draft) => {
|
||||
draft.input = props.input().plainText
|
||||
})
|
||||
}
|
||||
setStore("visible", false)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribeMention = editor.onMention((mention) => {
|
||||
insertFileMention(mention)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
unsubscribeMention()
|
||||
})
|
||||
|
||||
props.ref({
|
||||
get visible() {
|
||||
return store.visible
|
||||
},
|
||||
onInput(value) {
|
||||
if (store.visible) {
|
||||
if (
|
||||
// Typed text before the trigger
|
||||
props.input().cursorOffset <= store.index ||
|
||||
// There is a space between the trigger and the cursor
|
||||
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
|
||||
// "/<command>" is not the sole content
|
||||
(store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
|
||||
) {
|
||||
hide()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if autocomplete should reopen (e.g., after backspace deleted a space)
|
||||
const offset = props.input().cursorOffset
|
||||
if (offset === 0) return
|
||||
|
||||
// Check for "/" at position 0 - reopen slash commands
|
||||
if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
|
||||
show("/")
|
||||
setStore("index", 0)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
|
||||
const idx = mentionTriggerIndex(value, offset)
|
||||
if (idx !== undefined) {
|
||||
show("@")
|
||||
setStore("index", idx)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const height = createMemo(() => {
|
||||
const count = options().length || 1
|
||||
if (!store.visible) return Math.min(10, count)
|
||||
positionTick()
|
||||
return Math.min(10, count, Math.max(1, props.anchor().y))
|
||||
})
|
||||
|
||||
let scroll: ScrollBoxRenderable
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
|
||||
return (
|
||||
<box
|
||||
visible={store.visible !== false}
|
||||
position="absolute"
|
||||
top={position().y - height()}
|
||||
left={position().x}
|
||||
width={position().width}
|
||||
zIndex={100}
|
||||
{...SplitBorder}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<scrollbox
|
||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||
backgroundColor={theme.backgroundMenu}
|
||||
height={height()}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
<Index
|
||||
each={options()}
|
||||
fallback={
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={theme.textMuted}>No matching items</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(option, index) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={index === store.selected ? theme.primary : undefined}
|
||||
flexDirection="row"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
if (store.input !== "mouse") return
|
||||
moveTo(index)
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
setStore("input", "mouse")
|
||||
moveTo(index)
|
||||
}}
|
||||
onMouseUp={() => select()}
|
||||
>
|
||||
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
|
||||
{option().display}
|
||||
</text>
|
||||
<Show when={option().description}>
|
||||
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
|
||||
{option().description}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Index>
|
||||
</scrollbox>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
0
packages/tui/src/component/prompt/cwd.ts
Normal file
0
packages/tui/src/component/prompt/cwd.ts
Normal file
1
packages/tui/src/component/prompt/frecency.tsx
Normal file
1
packages/tui/src/component/prompt/frecency.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../prompt/frecency"
|
||||
1
packages/tui/src/component/prompt/history.tsx
Normal file
1
packages/tui/src/component/prompt/history.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../prompt/history"
|
||||
1697
packages/tui/src/component/prompt/index.tsx
Normal file
1697
packages/tui/src/component/prompt/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
48
packages/tui/src/component/prompt/local-attachment.ts
Normal file
48
packages/tui/src/component/prompt/local-attachment.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export type LocalFiles = Readonly<{
|
||||
readText(path: string): Promise<string>
|
||||
readBytes(path: string): Promise<Uint8Array>
|
||||
mime(path: string): Promise<string>
|
||||
}>
|
||||
|
||||
export type LocalAttachment =
|
||||
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
|
||||
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
|
||||
|
||||
export function readLocalAttachment(file: string) {
|
||||
return readLocalAttachmentWith(
|
||||
{
|
||||
readText: (value) => readFile(value, "utf8"),
|
||||
readBytes: (value) => readFile(value),
|
||||
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
|
||||
},
|
||||
file,
|
||||
)
|
||||
}
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".avif": "image/avif",
|
||||
".gif": "image/gif",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
|
||||
const mime = await files.mime(path).catch(() => undefined)
|
||||
if (!mime) return
|
||||
if (mime === "image/svg+xml") {
|
||||
const content = await files.readText(path).catch(() => undefined)
|
||||
if (!content) return
|
||||
return { type: "text", mime, content }
|
||||
}
|
||||
if (!mime.startsWith("image/") && mime !== "application/pdf") return
|
||||
const content = await files.readBytes(path).catch(() => undefined)
|
||||
if (!content) return
|
||||
return { type: "binary", mime, content }
|
||||
}
|
||||
193
packages/tui/src/component/prompt/move.tsx
Normal file
193
packages/tui/src/component/prompt/move.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import path from "path"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
|
||||
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
|
||||
import { useHomeSessionDestination } from "../../routes/home/session-destination"
|
||||
|
||||
function moveReminderText(directory: string) {
|
||||
return `<system-reminder>The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
|
||||
}
|
||||
|
||||
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const homeDestination = useHomeSessionDestination()
|
||||
const paths = useTuiPaths()
|
||||
const [creating, setCreating] = createSignal(false)
|
||||
const [creatingDots, setCreatingDots] = createSignal(3)
|
||||
const [progress, setProgress] = createSignal<string>()
|
||||
|
||||
async function create(context?: string) {
|
||||
const projectID = input.projectID()
|
||||
if (!projectID) return
|
||||
setCreating(true)
|
||||
setProgress("Creating copy")
|
||||
try {
|
||||
const result = await sdk.client.experimental.projectCopy.create(
|
||||
{
|
||||
projectID,
|
||||
strategy: "git_worktree",
|
||||
directory: path.join(paths.worktree, projectID.slice(0, 6)),
|
||||
context,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
const directory = result.data?.directory
|
||||
if (!directory) throw new Error("No project copy directory returned")
|
||||
|
||||
// Call a location-based route to make sure it's bootstrapped
|
||||
// before moving on
|
||||
await sdk.client.path.get({ directory }, { throwOnError: true })
|
||||
|
||||
setProgress("Creating session")
|
||||
return directory
|
||||
} catch (err) {
|
||||
homeDestination?.clear()
|
||||
setProgress(undefined)
|
||||
setCreating(false)
|
||||
toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
const projectID = input.projectID()
|
||||
if (!projectID) return
|
||||
const sessionID = input.sessionID()
|
||||
const session = sessionID ? sync.session.get(sessionID) : undefined
|
||||
dialog.replace(() => (
|
||||
<DialogMoveSession
|
||||
projectID={projectID}
|
||||
current={
|
||||
homeDestination?.destination() ??
|
||||
(session
|
||||
? {
|
||||
type: "directory",
|
||||
directory: session.directory,
|
||||
subdirectory: !!session.path,
|
||||
}
|
||||
: undefined)
|
||||
}
|
||||
onSelect={(selection) => {
|
||||
const sessionID = input.sessionID()
|
||||
if (!sessionID) {
|
||||
homeDestination?.setDestination(selection)
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
void moveExistingSession(sessionID, selection)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function sessionContext(sessionID: string) {
|
||||
const session = sync.session.get(sessionID)
|
||||
const messages = (sync.data.message[sessionID] ?? [])
|
||||
.slice(-6)
|
||||
.map((message) =>
|
||||
[
|
||||
message.role + ":",
|
||||
...(sync.data.part[message.id] ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])),
|
||||
].join(" "),
|
||||
)
|
||||
return [session?.title, ...messages].filter(Boolean).join("\n") || undefined
|
||||
}
|
||||
|
||||
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
|
||||
const session = sync.session.get(sessionID)
|
||||
const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined)
|
||||
const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no"
|
||||
if (!choice) return
|
||||
dialog.clear()
|
||||
const directory = selection.type === "new" ? await create(sessionContext(sessionID)) : selection.directory
|
||||
if (!directory) {
|
||||
setProgress(undefined)
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
setProgress("Moving session")
|
||||
try {
|
||||
await sdk.client.experimental.controlPlane.moveSession(
|
||||
{
|
||||
sessionID,
|
||||
destination: { directory },
|
||||
moveChanges: choice === "yes",
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
await sdk.client.session
|
||||
.promptAsync({
|
||||
sessionID,
|
||||
directory,
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: moveReminderText(directory),
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.catch(() => undefined)
|
||||
dialog.clear()
|
||||
} catch (error) {
|
||||
toast.error(error)
|
||||
dialog.clear()
|
||||
} finally {
|
||||
setProgress(undefined)
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pending = createMemo(() => Boolean(homeDestination?.destination()))
|
||||
const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new")
|
||||
|
||||
async function getDirectory(context?: string) {
|
||||
const value = homeDestination?.destination()
|
||||
if (!value) return
|
||||
if (value.type === "directory") {
|
||||
return value.directory
|
||||
}
|
||||
return await create(context)
|
||||
}
|
||||
|
||||
function startSubmit() {
|
||||
if (progress()) setProgress("Submitting prompt")
|
||||
}
|
||||
|
||||
function finishSubmit() {
|
||||
homeDestination?.clear()
|
||||
setProgress(undefined)
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!creating()) {
|
||||
setCreatingDots(3)
|
||||
return
|
||||
}
|
||||
const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
return {
|
||||
creating,
|
||||
creatingDots,
|
||||
finishSubmit,
|
||||
getDirectory,
|
||||
open,
|
||||
pending,
|
||||
pendingNew,
|
||||
progress,
|
||||
startSubmit,
|
||||
}
|
||||
}
|
||||
1
packages/tui/src/component/prompt/stash.tsx
Normal file
1
packages/tui/src/component/prompt/stash.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../prompt/stash"
|
||||
137
packages/tui/src/component/prompt/workspace.tsx
Normal file
137
packages/tui/src/component/prompt/workspace.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import {
|
||||
confirmWorkspaceFileChanges,
|
||||
openWorkspaceSelect,
|
||||
warpWorkspaceSession,
|
||||
type WorkspaceSelection,
|
||||
} from "../dialog-workspace-create"
|
||||
import type { WorkspaceStatus } from "../workspace-label"
|
||||
|
||||
export function usePromptWorkspace(sessionID?: string) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const [selection, setSelection] = createSignal<WorkspaceSelection>()
|
||||
const [creating, setCreating] = createSignal(false)
|
||||
const [creatingDots, setCreatingDots] = createSignal(3)
|
||||
const [notice, setNotice] = createSignal<string>()
|
||||
|
||||
async function create(selection: Extract<WorkspaceSelection, { type: "new" }>) {
|
||||
setCreating(true)
|
||||
let result
|
||||
try {
|
||||
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
|
||||
} catch (err) {
|
||||
setSelection(undefined)
|
||||
setCreating(false)
|
||||
toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
|
||||
return
|
||||
}
|
||||
if (result.error || !result.data) {
|
||||
setSelection(undefined)
|
||||
setCreating(false)
|
||||
toast.show({
|
||||
title: "Creating workspace failed",
|
||||
message: errorMessage(result.error ?? "no response"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await project.workspace.sync()
|
||||
const workspace = result.data
|
||||
setSelection({
|
||||
type: "existing",
|
||||
workspaceID: workspace.id,
|
||||
workspaceType: workspace.type,
|
||||
workspaceName: workspace.name,
|
||||
})
|
||||
setCreating(false)
|
||||
return workspace
|
||||
}
|
||||
|
||||
async function warp(selection: WorkspaceSelection) {
|
||||
if (!sessionID) {
|
||||
setSelection(selection)
|
||||
dialog.clear()
|
||||
if (selection.type === "new") void create(selection)
|
||||
return
|
||||
}
|
||||
const sourceWorkspaceID = project.workspace.current()
|
||||
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
|
||||
if (copyChanges === undefined) return
|
||||
setSelection(selection)
|
||||
dialog.clear()
|
||||
|
||||
const workspace =
|
||||
selection.type === "none"
|
||||
? { id: null, name: "local project" }
|
||||
: selection.type === "existing"
|
||||
? { id: selection.workspaceID, name: selection.workspaceName }
|
||||
: await create(selection)
|
||||
if (!workspace) return
|
||||
|
||||
const warped = await warpWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
sourceWorkspaceID,
|
||||
workspaceID: workspace.id,
|
||||
sessionID,
|
||||
copyChanges,
|
||||
})
|
||||
if (warped) showNotice(workspace.name)
|
||||
}
|
||||
|
||||
function showNotice(name: string) {
|
||||
setNotice(`Warped to ${name}`)
|
||||
setTimeout(() => setNotice(undefined), 4000)
|
||||
}
|
||||
|
||||
function clearNotice() {
|
||||
setNotice(undefined)
|
||||
}
|
||||
|
||||
function open() {
|
||||
void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: warp })
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!creating()) {
|
||||
setCreatingDots(3)
|
||||
return
|
||||
}
|
||||
const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
const label = createMemo<
|
||||
| { type: "new"; workspaceType: string }
|
||||
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
|
||||
| undefined
|
||||
>(() => {
|
||||
const selected = selection()
|
||||
if (!selected) return
|
||||
if (selected.type === "none") return
|
||||
if (sessionID && !creating()) return
|
||||
if (selected.type === "new") return { type: "new", workspaceType: selected.workspaceType }
|
||||
return {
|
||||
type: "existing",
|
||||
workspaceType: selected.workspaceType,
|
||||
workspaceName: selected.workspaceName,
|
||||
status: selected.type === "existing" ? "connected" : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
return { selection, creating, creatingDots, notice, label, open, warp, clearNotice }
|
||||
}
|
||||
24
packages/tui/src/component/spinner.tsx
Normal file
24
packages/tui/src/component/spinner.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useKV } from "../context/kv"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import "opentui-spinner/solid"
|
||||
|
||||
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
const { theme } = useTheme()
|
||||
const kv = useKV()
|
||||
const color = () => props.color ?? theme.textMuted
|
||||
return (
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯ {props.children}</text>}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
<Show when={props.children}>
|
||||
<text fg={color()}>{props.children}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
63
packages/tui/src/component/startup-loading.tsx
Normal file
63
packages/tui/src/component/startup-loading.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function StartupLoading(props: { ready: () => boolean }) {
|
||||
const theme = useTheme().theme
|
||||
const [show, setShow] = createSignal(false)
|
||||
const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
|
||||
let wait: NodeJS.Timeout | undefined
|
||||
let hold: NodeJS.Timeout | undefined
|
||||
let stamp = 0
|
||||
|
||||
createEffect(() => {
|
||||
if (props.ready()) {
|
||||
if (wait) {
|
||||
clearTimeout(wait)
|
||||
wait = undefined
|
||||
}
|
||||
if (!show()) return
|
||||
if (hold) return
|
||||
|
||||
const left = 3000 - (Date.now() - stamp)
|
||||
if (left <= 0) {
|
||||
setShow(false)
|
||||
return
|
||||
}
|
||||
|
||||
hold = setTimeout(() => {
|
||||
hold = undefined
|
||||
setShow(false)
|
||||
}, left).unref()
|
||||
return
|
||||
}
|
||||
|
||||
if (hold) {
|
||||
clearTimeout(hold)
|
||||
hold = undefined
|
||||
}
|
||||
if (show()) return
|
||||
if (wait) return
|
||||
|
||||
wait = setTimeout(() => {
|
||||
wait = undefined
|
||||
stamp = Date.now()
|
||||
setShow(true)
|
||||
}, 500).unref()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (wait) clearTimeout(wait)
|
||||
if (hold) clearTimeout(hold)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={show()}>
|
||||
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
|
||||
<box backgroundColor={theme.backgroundPanel} paddingLeft={1} paddingRight={1}>
|
||||
<Spinner color={theme.textMuted}>{text()}</Spinner>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
32
packages/tui/src/component/todo-item.tsx
Normal file
32
packages/tui/src/component/todo-item.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useTheme } from "../context/theme"
|
||||
|
||||
export interface TodoItemProps {
|
||||
status: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export function TodoItem(props: TodoItemProps) {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<box flexDirection="row" gap={0}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: props.status === "in_progress" ? theme.warning : theme.textMuted,
|
||||
}}
|
||||
>
|
||||
[{props.status === "completed" ? "✓" : props.status === "in_progress" ? "•" : " "}]{" "}
|
||||
</text>
|
||||
<text
|
||||
flexGrow={1}
|
||||
wrapMode="word"
|
||||
style={{
|
||||
fg: props.status === "in_progress" ? theme.warning : theme.textMuted,
|
||||
}}
|
||||
>
|
||||
{props.content}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
12
packages/tui/src/component/use-connected.tsx
Normal file
12
packages/tui/src/component/use-connected.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSync } from "../context/sync"
|
||||
|
||||
export function useConnected() {
|
||||
const sync = useSync()
|
||||
return createMemo(() =>
|
||||
sync.data.provider.some(
|
||||
(provider) =>
|
||||
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost?.input !== 0),
|
||||
),
|
||||
)
|
||||
}
|
||||
19
packages/tui/src/component/workspace-label.tsx
Normal file
19
packages/tui/src/component/workspace-label.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useTheme } from "../context/theme"
|
||||
|
||||
export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||
|
||||
export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) {
|
||||
const { theme } = useTheme()
|
||||
const color = () => {
|
||||
if (props.status === "connected") return theme.success
|
||||
if (props.status === "error") return theme.error
|
||||
return theme.textMuted
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.icon ? <span style={{ fg: color() }}>● </span> : undefined}
|
||||
<span style={{ fg: theme.text }}>{props.name}</span> <span style={{ fg: theme.textMuted }}>({props.type})</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user