@kitlangton/ghui 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/colors.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type ThemeId =
2
+ | "system"
2
3
  | "ghui"
3
4
  | "tokyo-night"
4
5
  | "catppuccin"
@@ -60,6 +61,99 @@ export interface ThemeDefinition {
60
61
  readonly colors: ColorPalette
61
62
  }
62
63
 
64
+ interface TerminalThemeColors {
65
+ readonly palette: readonly (string | null)[]
66
+ readonly defaultForeground: string | null
67
+ readonly defaultBackground: string | null
68
+ readonly highlightBackground: string | null
69
+ readonly highlightForeground: string | null
70
+ }
71
+
72
+ const readableHex = (value: string | null | undefined, fallback: string) =>
73
+ typeof value === "string" && /^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$/.test(value) ? value : fallback
74
+
75
+ const hexToRgb = (hex: string) => {
76
+ const value = hex.replace(/^#/, "").slice(0, 6)
77
+ return {
78
+ r: parseInt(value.slice(0, 2), 16),
79
+ g: parseInt(value.slice(2, 4), 16),
80
+ b: parseInt(value.slice(4, 6), 16),
81
+ }
82
+ }
83
+
84
+ const luminance = (hex: string) => {
85
+ const { r, g, b } = hexToRgb(hex)
86
+ return 0.299 * r + 0.587 * g + 0.114 * b
87
+ }
88
+
89
+ const rgbToHex = ({ r, g, b }: { readonly r: number; readonly g: number; readonly b: number }) =>
90
+ `#${[r, g, b].map((component) => Math.max(0, Math.min(255, Math.round(component))).toString(16).padStart(2, "0")).join("")}`
91
+
92
+ export const mixHex = (base: string, overlay: string, amount: number) => {
93
+ const from = hexToRgb(base)
94
+ const to = hexToRgb(overlay)
95
+ return rgbToHex({
96
+ r: from.r + (to.r - from.r) * amount,
97
+ g: from.g + (to.g - from.g) * amount,
98
+ b: from.b + (to.b - from.b) * amount,
99
+ })
100
+ }
101
+
102
+ const grayscaleRamp = (background: string) => {
103
+ const bg = hexToRgb(background)
104
+ const bgLum = luminance(background)
105
+ const isDark = bgLum < 128
106
+ const grays: Record<number, string> = {}
107
+
108
+ for (let i = 1; i <= 12; i++) {
109
+ const factor = i / 12
110
+ let r: number
111
+ let g: number
112
+ let b: number
113
+
114
+ if (isDark) {
115
+ if (bgLum < 10) {
116
+ const value = Math.floor(factor * 0.4 * 255)
117
+ r = value
118
+ g = value
119
+ b = value
120
+ } else {
121
+ const nextLum = bgLum + (255 - bgLum) * factor * 0.4
122
+ const ratio = nextLum / bgLum
123
+ r = Math.min(bg.r * ratio, 255)
124
+ g = Math.min(bg.g * ratio, 255)
125
+ b = Math.min(bg.b * ratio, 255)
126
+ }
127
+ } else if (bgLum > 245) {
128
+ const value = Math.floor(255 - factor * 0.4 * 255)
129
+ r = value
130
+ g = value
131
+ b = value
132
+ } else {
133
+ const nextLum = bgLum * (1 - factor * 0.4)
134
+ const ratio = nextLum / bgLum
135
+ r = Math.max(bg.r * ratio, 0)
136
+ g = Math.max(bg.g * ratio, 0)
137
+ b = Math.max(bg.b * ratio, 0)
138
+ }
139
+
140
+ grays[i] = rgbToHex({ r, g, b })
141
+ }
142
+
143
+ return grays
144
+ }
145
+
146
+ const mutedTextColor = (background: string) => {
147
+ const bgLum = luminance(background)
148
+ const isDark = bgLum < 128
149
+ const value = isDark
150
+ ? bgLum < 10 ? 180 : Math.min(Math.floor(160 + bgLum * 0.3), 200)
151
+ : bgLum > 245 ? 75 : Math.max(Math.floor(100 - (255 - bgLum) * 0.2), 60)
152
+ return rgbToHex({ r: value, g: value, b: value })
153
+ }
154
+
155
+ const contrastText = (background: string) => luminance(background) > 128 ? "#000000" : "#ffffff"
156
+
63
157
  const ghuiColors: ColorPalette = {
64
158
  background: "#111018",
65
159
  modalBackground: "#1a1a2e",
@@ -99,6 +193,70 @@ const ghuiColors: ColorPalette = {
99
193
  },
100
194
  }
101
195
 
196
+ const makeSystemColors = (terminal?: TerminalThemeColors): ColorPalette => {
197
+ const palette = terminal?.palette ?? []
198
+ const terminalBackground = readableHex(terminal?.defaultBackground, readableHex(palette[0], "#000000"))
199
+ const text = readableHex(terminal?.defaultForeground, readableHex(palette[7], "#ffffff"))
200
+ const grays = grayscaleRamp(terminalBackground)
201
+ const isDark = luminance(terminalBackground) < 128
202
+ const red = readableHex(palette[1], "#cc0000")
203
+ const green = readableHex(palette[2], "#4e9a06")
204
+ const yellow = readableHex(palette[3], "#c4a000")
205
+ const blue = readableHex(palette[4], "#3465a4")
206
+ const magenta = readableHex(palette[5], "#75507b")
207
+ const brightBlack = readableHex(palette[8], mutedTextColor(terminalBackground))
208
+ const brightGreen = readableHex(palette[10], green)
209
+ const brightBlue = readableHex(palette[12], blue)
210
+ const brightMagenta = readableHex(palette[13], magenta)
211
+ const primary = brightBlue
212
+ const panel = grays[2] ?? mixHex(terminalBackground, text, isDark ? 0.07 : 0.08)
213
+ const element = grays[3] ?? mixHex(terminalBackground, text, isDark ? 0.1 : 0.1)
214
+ const border = grays[7] ?? mixHex(terminalBackground, text, isDark ? 0.24 : 0.24)
215
+ const borderSubtle = grays[6] ?? border
216
+ const diffAlpha = isDark ? 0.22 : 0.14
217
+
218
+ return {
219
+ background: "transparent",
220
+ modalBackground: panel,
221
+ text,
222
+ muted: mutedTextColor(terminalBackground),
223
+ separator: border,
224
+ accent: primary,
225
+ inlineCode: brightGreen,
226
+ error: red,
227
+ selectedBg: primary,
228
+ selectedText: contrastText(primary),
229
+ count: primary,
230
+ status: {
231
+ draft: yellow,
232
+ approved: green,
233
+ changes: red,
234
+ review: primary,
235
+ none: brightBlack,
236
+ passing: green,
237
+ pending: yellow,
238
+ failing: red,
239
+ },
240
+ repos: {
241
+ opencode: primary,
242
+ "effect-smol": green,
243
+ "opencode-console": brightMagenta,
244
+ opencontrol: yellow,
245
+ default: blue,
246
+ },
247
+ diff: {
248
+ addedBg: mixHex(terminalBackground, green, diffAlpha),
249
+ removedBg: mixHex(terminalBackground, red, diffAlpha),
250
+ contextBg: panel,
251
+ lineNumberBg: borderSubtle,
252
+ addedLineNumberBg: mixHex(element, green, diffAlpha),
253
+ removedLineNumberBg: mixHex(element, red, diffAlpha),
254
+ },
255
+ }
256
+ }
257
+
258
+ const systemColors: ColorPalette = makeSystemColors()
259
+
102
260
  const tokyoNightColors: ColorPalette = {
103
261
  background: "#1a1b26",
104
262
  modalBackground: "#24283b",
@@ -607,6 +765,7 @@ const vesperColors: ColorPalette = {
607
765
  }
608
766
 
609
767
  export const themeDefinitions: readonly ThemeDefinition[] = [
768
+ { id: "system", name: "System", description: "Use the terminal foreground, background, and ANSI palette", colors: systemColors },
610
769
  { id: "ghui", name: "GHUI", description: "Warm parchment accents on a deep slate background", colors: ghuiColors },
611
770
  { id: "tokyo-night", name: "Tokyo Night", description: "Cool indigo surfaces with neon editor accents", colors: tokyoNightColors },
612
771
  { id: "catppuccin", name: "Catppuccin", description: "Mocha lavender, peach, and soft pastel contrast", colors: catppuccinColors },
@@ -623,7 +782,7 @@ export const themeDefinitions: readonly ThemeDefinition[] = [
623
782
  { id: "opencode", name: "OpenCode", description: "Charcoal panels with peach, violet, and blue highlights", colors: opencodeColors },
624
783
  ] as const
625
784
 
626
- let activeTheme = themeDefinitions[0]!
785
+ let activeTheme = themeDefinitions.find((theme) => theme.id === "ghui") ?? themeDefinitions[0]!
627
786
 
628
787
  export const colors: ColorPalette = { ...ghuiColors }
629
788
 
@@ -646,3 +805,10 @@ export const setActiveTheme = (id: ThemeId) => {
646
805
  activeTheme = getThemeDefinition(id)
647
806
  Object.assign(colors, activeTheme.colors)
648
807
  }
808
+
809
+ export const setSystemThemeColors = (terminalColors: TerminalThemeColors) => {
810
+ Object.assign(systemColors, makeSystemColors(terminalColors))
811
+ if (activeTheme.id === "system") {
812
+ Object.assign(colors, systemColors)
813
+ }
814
+ }
package/src/ui/diff.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { parseColor, SyntaxStyle } from "@opentui/core"
1
+ import { parseColor, pathToFiletype, SyntaxStyle } from "@opentui/core"
2
2
  import { Data, Schema } from "effect"
3
3
  import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
4
4
  import { colors } from "./colors.js"
@@ -73,55 +73,45 @@ export const createDiffSyntaxStyle = () => SyntaxStyle.fromStyles({
73
73
  default: { fg: parseColor(colors.text) },
74
74
  })
75
75
 
76
- const extensionFiletypes: Record<string, string> = {
77
- c: "c",
78
- cc: "cpp",
79
- cpp: "cpp",
80
- cs: "csharp",
81
- css: "css",
82
- go: "go",
83
- h: "c",
84
- hpp: "cpp",
85
- html: "html",
86
- java: "java",
87
- js: "javascript",
88
- jsx: "javascript",
89
- json: "json",
90
- kt: "kotlin",
91
- md: "markdown",
92
- mjs: "javascript",
93
- py: "python",
94
- rs: "rust",
95
- rb: "ruby",
96
- sh: "bash",
97
- svelte: "svelte",
98
- toml: "toml",
99
- ts: "typescript",
100
- tsx: "typescript",
101
- txt: "text",
102
- vue: "vue",
103
- yaml: "yaml",
104
- yml: "yaml",
105
- zig: "zig",
106
- }
76
+ const unquoteDiffPath = (path: string) => path.replace(/^"|"$/g, "").replace(/^a\//, "").replace(/^b\//, "")
77
+
78
+ const readDiffPath = (value: string, start: number) => {
79
+ if (value[start] === '"') {
80
+ for (let index = start + 1; index < value.length; index++) {
81
+ if (value[index] === '"' && value[index - 1] !== "\\") {
82
+ const raw = value.slice(start, index + 1)
83
+ try {
84
+ return { path: JSON.parse(raw) as string, end: index + 1 }
85
+ } catch {
86
+ return { path: raw, end: index + 1 }
87
+ }
88
+ }
89
+ }
90
+ }
107
91
 
108
- const filetypeForPath = (path: string) => {
109
- const basename = path.split("/").at(-1) ?? path
110
- if (basename === "Dockerfile") return "dockerfile"
111
- const extension = basename.includes(".") ? basename.split(".").at(-1)?.toLowerCase() : undefined
112
- return extension ? extensionFiletypes[extension] : undefined
92
+ const end = value.slice(start).search(/\s/)
93
+ const pathEnd = end >= 0 ? start + end : value.length
94
+ return { path: value.slice(start, pathEnd), end: pathEnd }
113
95
  }
114
96
 
115
- const unquoteDiffPath = (path: string) => path.replace(/^"|"$/g, "").replace(/^a\//, "").replace(/^b\//, "")
97
+ const parseDiffGitPaths = (line: string) => {
98
+ const prefix = "diff --git "
99
+ if (!line.startsWith(prefix)) return null
100
+ const left = readDiffPath(line, prefix.length)
101
+ const rightStart = line.slice(left.end).search(/\S/)
102
+ if (rightStart < 0) return null
103
+ const right = readDiffPath(line, left.end + rightStart)
104
+ return [left.path, right.path] as const
105
+ }
116
106
 
117
107
  const patchFileName = (patch: string) => {
118
108
  const diffLine = patch.split("\n").find((line) => line.startsWith("diff --git "))
119
109
  if (diffLine) {
120
- const match = diffLine.match(/^diff --git\s+(\S+)\s+(\S+)/)
121
- if (match) {
122
- const next = unquoteDiffPath(match[2]!)
110
+ const paths = parseDiffGitPaths(diffLine)
111
+ if (paths) {
112
+ const next = unquoteDiffPath(paths[1])
123
113
  if (next !== "/dev/null") return next
124
- return unquoteDiffPath(match[1]!)
114
+ return unquoteDiffPath(paths[0])
125
115
  }
126
116
  }
127
117
 
@@ -178,11 +168,11 @@ export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
178
168
  const end = index + 1 < matches.length ? matches[index + 1]!.index ?? trimmed.length : trimmed.length
179
169
  const filePatch = normalizeHunkLineCounts(trimmed.slice(start, end).trimEnd())
180
170
  const name = patchFileName(filePatch)
181
- return { name, filetype: filetypeForPath(name), patch: filePatch }
171
+ return { name, filetype: pathToFiletype(name), patch: filePatch }
182
172
  })
183
173
  }
184
174
 
185
- export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}`
175
+ export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}:${pullRequest.headRefOid}`
186
176
 
187
177
  export const safeDiffFileIndex = (files: readonly DiffFilePatch[], index: number) =>
188
178
  files.length > 0 ? Math.max(0, Math.min(index, files.length - 1)) : 0
@@ -0,0 +1,25 @@
1
+ import type { PullRequestItem } from "../domain.js"
2
+ import { colors } from "./colors.js"
3
+
4
+ type DiffStatsPart = { readonly key: string; readonly text: string; readonly color: string }
5
+
6
+ const diffStatsParts = (pullRequest: PullRequestItem): readonly DiffStatsPart[] => {
7
+ const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
8
+ return [
9
+ pullRequest.additions > 0 ? { key: "additions", text: `+${pullRequest.additions}`, color: colors.status.passing } : null,
10
+ pullRequest.deletions > 0 ? { key: "deletions", text: `-${pullRequest.deletions}`, color: colors.status.failing } : null,
11
+ { key: "files", text: files, color: colors.muted },
12
+ ].filter((part): part is DiffStatsPart => part !== null)
13
+ }
14
+
15
+ export const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
16
+ if (!pullRequest.detailLoaded) return <span fg={colors.muted}>loading details</span>
17
+ const parts = diffStatsParts(pullRequest)
18
+ return (
19
+ <>
20
+ {parts.map((part, index) => (
21
+ <span key={part.key} fg={part.color}>{`${index > 0 ? " " : ""}${part.text}`}</span>
22
+ ))}
23
+ </>
24
+ )
25
+ }