stage-tui 1.0.7

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.
@@ -0,0 +1,65 @@
1
+ import { useEffect, useState } from "react"
2
+
3
+ import { fitFooterLine } from "../utils"
4
+ import type { UiTheme } from "../theme"
5
+
6
+ type FooterBarProps = {
7
+ statusMessage: string
8
+ showShortcutsHint: boolean
9
+ terminalWidth: number
10
+ fatalError: string | null
11
+ isBusy: boolean
12
+ theme: UiTheme
13
+ }
14
+
15
+ const SPINNER_FRAMES = "⣾⣽⣻⢿⡿⣟⣯⣷"
16
+
17
+ export function FooterBar({ statusMessage, showShortcutsHint, terminalWidth, fatalError, isBusy, theme }: FooterBarProps) {
18
+ const [spinnerIndex, setSpinnerIndex] = useState(0)
19
+
20
+ useEffect(() => {
21
+ if (!isBusy) {
22
+ setSpinnerIndex(0)
23
+ return
24
+ }
25
+
26
+ const timer = setInterval(() => {
27
+ setSpinnerIndex((current) => (current + 1) % SPINNER_FRAMES.length)
28
+ }, 90)
29
+
30
+ return () => clearInterval(timer)
31
+ }, [isBusy])
32
+
33
+ const busyPrefix = isBusy ? `${SPINNER_FRAMES[spinnerIndex]} ` : ""
34
+ const statusWithSpinner = `${busyPrefix}${statusMessage}`
35
+ const footerInnerWidth = Math.max(terminalWidth - 2, 0)
36
+ const shortcutsHint = showShortcutsHint ? "[?] shortcuts" : ""
37
+
38
+ if (!shortcutsHint) {
39
+ const footerLine = fitFooterLine(statusWithSpinner, footerInnerWidth)
40
+ return (
41
+ <box style={{ height: 1, paddingLeft: 1, paddingRight: 1 }}>
42
+ <text fg={fatalError ? theme.colors.footerError : isBusy ? theme.colors.footerBusy : theme.colors.footerReady}>{footerLine}</text>
43
+ </box>
44
+ )
45
+ }
46
+
47
+ if (shortcutsHint.length >= footerInnerWidth) {
48
+ const hintOnlyLine = shortcutsHint.slice(0, footerInnerWidth)
49
+ return (
50
+ <box style={{ height: 1, paddingLeft: 1, paddingRight: 1 }}>
51
+ <text fg={theme.colors.subtleText}>{hintOnlyLine}</text>
52
+ </box>
53
+ )
54
+ }
55
+
56
+ const leftWidth = Math.max(footerInnerWidth - shortcutsHint.length - 1, 0)
57
+ const leftLine = fitFooterLine(statusWithSpinner, leftWidth)
58
+
59
+ return (
60
+ <box style={{ height: 1, paddingLeft: 1, paddingRight: 1, flexDirection: "row" }}>
61
+ <text fg={fatalError ? theme.colors.footerError : isBusy ? theme.colors.footerBusy : theme.colors.footerReady}>{leftLine}</text>
62
+ <text fg={theme.colors.subtleText}> {shortcutsHint}</text>
63
+ </box>
64
+ )
65
+ }
@@ -0,0 +1,53 @@
1
+ import type { UiTheme } from "../theme"
2
+
3
+ type ShortcutsDialogProps = {
4
+ open: boolean
5
+ aiCommitEnabled: boolean
6
+ theme: UiTheme
7
+ }
8
+
9
+ const BASE_SHORTCUT_ROWS: ReadonlyArray<readonly [string, string]> = [
10
+ ["?", "show/hide shortcuts"],
11
+ ["b", "⎇ change branch"],
12
+ ["h", "◷ open commit history"],
13
+ ["space", "include/exclude file in commit"],
14
+ ["↑ / ↓", "move file selection"],
15
+ ["r", "↻ refresh"],
16
+ ["f", "⇣ fetch"],
17
+ ["l", "⇩ pull"],
18
+ ["p", "⇧ push"],
19
+ ]
20
+
21
+ export function ShortcutsDialog({ open, aiCommitEnabled, theme }: ShortcutsDialogProps) {
22
+ if (!open) return null
23
+ const commitRow: readonly [string, string] = aiCommitEnabled
24
+ ? ["c", "✦ generate AI commit"]
25
+ : ["c", "✓ open commit dialog"]
26
+ const rows = [...BASE_SHORTCUT_ROWS, commitRow, ["esc", "close dialog or exit"]] as const
27
+
28
+ return (
29
+ <box
30
+ style={{
31
+ width: "100%",
32
+ flexGrow: 1,
33
+ paddingLeft: 6,
34
+ paddingRight: 6,
35
+ paddingTop: 8,
36
+ paddingBottom: 4,
37
+ }}
38
+ >
39
+ <box style={{ width: "100%", maxWidth: 72, flexDirection: "column", gap: 1 }}>
40
+ <text fg={theme.colors.title}>shortcuts</text>
41
+ <text fg={theme.colors.subtleText}>press ? or esc to close</text>
42
+ <box style={{ flexDirection: "column", marginTop: 1 }}>
43
+ {rows.map(([key, description]) => (
44
+ <box key={key} style={{ flexDirection: "row", gap: 2 }}>
45
+ <text fg={theme.colors.hintText}>{key.padEnd(8, " ")}</text>
46
+ <text fg={theme.colors.text}>{description}</text>
47
+ </box>
48
+ ))}
49
+ </box>
50
+ </box>
51
+ </box>
52
+ )
53
+ }
@@ -0,0 +1,36 @@
1
+ import type { UiTheme } from "../theme"
2
+
3
+ type TopBarProps = {
4
+ currentBranch: string
5
+ tracking: {
6
+ loading: boolean
7
+ upstream: string | null
8
+ ahead: number
9
+ behind: number
10
+ }
11
+ theme: UiTheme
12
+ }
13
+
14
+ export function TopBar({ currentBranch, tracking, theme }: TopBarProps) {
15
+ return (
16
+ <box
17
+ style={{ height: 3, flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingLeft: 1, paddingRight: 1 }}
18
+ >
19
+ <box style={{ flexDirection: "row", gap: 1 }}>
20
+ <text fg={theme.colors.mutedText}>⎇</text>
21
+ <text fg={theme.colors.text}>{currentBranch}</text>
22
+ </box>
23
+ {tracking.loading ? (
24
+ <text fg={theme.colors.subtleText}>… loading repository state</text>
25
+ ) : (
26
+ <box style={{ flexDirection: "row" }}>
27
+ {tracking.upstream ? <text fg={theme.colors.subtleText}>{tracking.upstream}</text> : null}
28
+ {!tracking.upstream ? <text fg={theme.colors.subtleText}>◌ unpublished</text> : null}
29
+ {tracking.upstream && tracking.ahead === 0 && tracking.behind === 0 ? <text fg={theme.colors.subtleText}> ✓ synced</text> : null}
30
+ {tracking.ahead > 0 ? <text fg={theme.colors.successText}> ↑{tracking.ahead}</text> : null}
31
+ {tracking.behind > 0 ? <text fg={theme.colors.warningText}> ↓{tracking.behind}</text> : null}
32
+ </box>
33
+ )}
34
+ </box>
35
+ )
36
+ }
@@ -0,0 +1,33 @@
1
+ import { RGBA, SyntaxStyle } from "@opentui/core"
2
+
3
+ type DiffStyleMode = "dark" | "light"
4
+
5
+ const DARK_DIFF_SYNTAX_STYLE = SyntaxStyle.fromStyles({
6
+ keyword: { fg: RGBA.fromHex("#c792ea"), italic: true },
7
+ string: { fg: RGBA.fromHex("#c3e88d") },
8
+ comment: { fg: RGBA.fromHex("#6a737d"), italic: true },
9
+ number: { fg: RGBA.fromHex("#f78c6c") },
10
+ function: { fg: RGBA.fromHex("#82aaff") },
11
+ type: { fg: RGBA.fromHex("#ffcb6b") },
12
+ variable: { fg: RGBA.fromHex("#f07178") },
13
+ operator: { fg: RGBA.fromHex("#89ddff") },
14
+ punctuation: { fg: RGBA.fromHex("#cdd6f4") },
15
+ default: { fg: RGBA.fromHex("#e6edf3") },
16
+ })
17
+
18
+ const LIGHT_DIFF_SYNTAX_STYLE = SyntaxStyle.fromStyles({
19
+ keyword: { fg: RGBA.fromHex("#7c3aed"), italic: true },
20
+ string: { fg: RGBA.fromHex("#15803d") },
21
+ comment: { fg: RGBA.fromHex("#6b7280"), italic: true },
22
+ number: { fg: RGBA.fromHex("#c2410c") },
23
+ function: { fg: RGBA.fromHex("#1d4ed8") },
24
+ type: { fg: RGBA.fromHex("#a16207") },
25
+ variable: { fg: RGBA.fromHex("#be123c") },
26
+ operator: { fg: RGBA.fromHex("#0e7490") },
27
+ punctuation: { fg: RGBA.fromHex("#334155") },
28
+ default: { fg: RGBA.fromHex("#111827") },
29
+ })
30
+
31
+ export function createDiffSyntaxStyle(mode: DiffStyleMode): SyntaxStyle {
32
+ return mode === "light" ? LIGHT_DIFF_SYNTAX_STYLE : DARK_DIFF_SYNTAX_STYLE
33
+ }
@@ -0,0 +1,151 @@
1
+ import type { SyntaxStyle } from "@opentui/core"
2
+ import { execFileSync } from "node:child_process"
3
+
4
+ import { createDiffSyntaxStyle } from "./diff-style"
5
+
6
+ export type UiThemeMode = "dark" | "light"
7
+ export type UiThemePreference = UiThemeMode | "auto"
8
+
9
+ type UiColors = {
10
+ title: string
11
+ text: string
12
+ mutedText: string
13
+ subtleText: string
14
+ hintText: string
15
+ successText: string
16
+ warningText: string
17
+ selectedRowBackground: string
18
+ selectText: string
19
+ selectSelectedBackground: string
20
+ selectSelectedText: string
21
+ selectFocusedText: string
22
+ inputText: string
23
+ inputFocusedText: string
24
+ secondaryInputText: string
25
+ footerReady: string
26
+ footerBusy: string
27
+ footerError: string
28
+ diffForeground: string
29
+ diffLineNumber: string
30
+ diffAddedBackground: string
31
+ diffRemovedBackground: string
32
+ diffAddedLineNumberBackground: string
33
+ diffRemovedLineNumberBackground: string
34
+ }
35
+
36
+ export type UiTheme = {
37
+ mode: UiThemeMode
38
+ colors: UiColors
39
+ diffSyntaxStyle: SyntaxStyle
40
+ }
41
+
42
+ const DARK_COLORS: UiColors = {
43
+ title: "#f5f5f5",
44
+ text: "#f3f4f6",
45
+ mutedText: "#737373",
46
+ subtleText: "#525252",
47
+ hintText: "#9ca3af",
48
+ successText: "#3fb950",
49
+ warningText: "#d29922",
50
+ selectedRowBackground: "#101010",
51
+ selectText: "#9ca3af",
52
+ selectSelectedBackground: "#111111",
53
+ selectSelectedText: "#ffffff",
54
+ selectFocusedText: "#f3f4f6",
55
+ inputText: "#f3f4f6",
56
+ inputFocusedText: "#f9fafb",
57
+ secondaryInputText: "#d1d5db",
58
+ footerReady: "#58a6ff",
59
+ footerBusy: "#d29922",
60
+ footerError: "#ff7b72",
61
+ diffForeground: "#e5e7eb",
62
+ diffLineNumber: "#525252",
63
+ diffAddedBackground: "#06180c",
64
+ diffRemovedBackground: "#220909",
65
+ diffAddedLineNumberBackground: "#0a2212",
66
+ diffRemovedLineNumberBackground: "#2c1010",
67
+ }
68
+
69
+ const LIGHT_COLORS: UiColors = {
70
+ title: "#111827",
71
+ text: "#1f2937",
72
+ mutedText: "#6b7280",
73
+ subtleText: "#9ca3af",
74
+ hintText: "#64748b",
75
+ successText: "#15803d",
76
+ warningText: "#b45309",
77
+ selectedRowBackground: "#e5e7eb",
78
+ selectText: "#334155",
79
+ selectSelectedBackground: "#dbeafe",
80
+ selectSelectedText: "#0f172a",
81
+ selectFocusedText: "#111827",
82
+ inputText: "#111827",
83
+ inputFocusedText: "#020617",
84
+ secondaryInputText: "#334155",
85
+ footerReady: "#1d4ed8",
86
+ footerBusy: "#b45309",
87
+ footerError: "#b91c1c",
88
+ diffForeground: "#111827",
89
+ diffLineNumber: "#6b7280",
90
+ diffAddedBackground: "#dff3e4",
91
+ diffRemovedBackground: "#fde2e2",
92
+ diffAddedLineNumberBackground: "#c7e9d0",
93
+ diffRemovedLineNumberBackground: "#f7c6c6",
94
+ }
95
+
96
+ const DARK_THEME: UiTheme = {
97
+ mode: "dark",
98
+ colors: DARK_COLORS,
99
+ diffSyntaxStyle: createDiffSyntaxStyle("dark"),
100
+ }
101
+
102
+ const LIGHT_THEME: UiTheme = {
103
+ mode: "light",
104
+ colors: LIGHT_COLORS,
105
+ diffSyntaxStyle: createDiffSyntaxStyle("light"),
106
+ }
107
+
108
+ const THEME_CACHE_TTL_MS = 1000
109
+ let cachedTheme: UiTheme | null = null
110
+ let cachedThemeAt = 0
111
+ let cachedPreference: UiThemePreference | null = null
112
+
113
+ export function resolveUiTheme(preference: UiThemePreference = "auto"): UiTheme {
114
+ const now = Date.now()
115
+ if (cachedTheme && cachedPreference === preference && now - cachedThemeAt < THEME_CACHE_TTL_MS) {
116
+ return cachedTheme
117
+ }
118
+
119
+ const mode = preference === "auto" ? resolveAutoThemeMode() : preference
120
+ cachedTheme = mode === "light" ? LIGHT_THEME : DARK_THEME
121
+ cachedThemeAt = now
122
+ cachedPreference = preference
123
+ return cachedTheme
124
+ }
125
+
126
+ function resolveAutoThemeMode(): UiThemeMode {
127
+ const explicitBackground = process.env.TERM_BACKGROUND?.trim().toLowerCase()
128
+ if (explicitBackground === "light") return "light"
129
+ if (explicitBackground === "dark") return "dark"
130
+
131
+ const osMode = resolveThemeModeFromMacOs()
132
+ if (osMode) return osMode
133
+
134
+ return "dark"
135
+ }
136
+
137
+ function resolveThemeModeFromMacOs(): UiThemeMode | null {
138
+ if (process.platform !== "darwin") return null
139
+
140
+ try {
141
+ const output = execFileSync("defaults", ["read", "-g", "AppleInterfaceStyle"], {
142
+ encoding: "utf8",
143
+ stdio: ["ignore", "pipe", "ignore"],
144
+ timeout: 200,
145
+ maxBuffer: 16 * 1024,
146
+ })
147
+ return output.trim().toLowerCase() === "dark" ? "dark" : "light"
148
+ } catch {
149
+ return "light"
150
+ }
151
+ }
@@ -0,0 +1,21 @@
1
+ export type FocusTarget =
2
+ | "files"
3
+ | "branch-dialog-list"
4
+ | "branch-create"
5
+ | "history-commits"
6
+ | "history-actions"
7
+ | "commit-summary"
8
+ | "commit-description"
9
+ export type TopAction = "refresh" | "fetch" | "pull" | "push" | "commit"
10
+
11
+ export const MAIN_FOCUS_ORDER: FocusTarget[] = ["files"]
12
+ export const COMMIT_FOCUS_ORDER: FocusTarget[] = ["commit-summary", "commit-description"]
13
+
14
+ export type FileRow = {
15
+ path: string
16
+ included: boolean
17
+ statusSymbol: string
18
+ statusColor: string
19
+ directory: string
20
+ filename: string
21
+ }
@@ -0,0 +1,99 @@
1
+ import type { ChangedFile } from "../git"
2
+ import type { FileRow } from "./types"
3
+
4
+ export function inferFiletype(path: string | undefined): string | undefined {
5
+ if (!path) return undefined
6
+ const extension = path.includes(".") ? path.split(".").pop() : undefined
7
+ if (!extension) return undefined
8
+ const normalized = extension.toLowerCase()
9
+
10
+ if (normalized === "ts" || normalized === "tsx") return "typescript"
11
+ if (normalized === "js" || normalized === "jsx" || normalized === "mjs" || normalized === "cjs") return "javascript"
12
+ if (normalized === "md" || normalized === "mdx") return "markdown"
13
+ if (normalized === "yml") return "yaml"
14
+ if (normalized === "sh" || normalized === "zsh") return "bash"
15
+
16
+ return normalized
17
+ }
18
+
19
+ export function fitFooterLine(text: string, width: number): string {
20
+ if (width <= 0) return text
21
+ if (text.length > width) return text.slice(0, width)
22
+ return text.padEnd(width, " ")
23
+ }
24
+
25
+ export function fitFooterStatusLine(left: string, right: string, width: number): string {
26
+ if (width <= 0) return ""
27
+
28
+ const rightText = right.trim()
29
+ if (!rightText) return fitFooterLine(left, width)
30
+ if (rightText.length >= width) return rightText.slice(0, width)
31
+
32
+ const leftWidth = Math.max(width - rightText.length - 1, 0)
33
+ const leftText = fitFooterLine(left, leftWidth)
34
+ return `${leftText} ${rightText}`
35
+ }
36
+
37
+ export function formatTrackingSummary(upstream: string | null, ahead: number, behind: number): string {
38
+ if (!upstream) {
39
+ return "◌ unpublished"
40
+ }
41
+ if (ahead === 0 && behind === 0) {
42
+ return "✓ synced"
43
+ }
44
+
45
+ const parts: string[] = []
46
+ if (ahead > 0) {
47
+ parts.push(`↑${ahead}`)
48
+ }
49
+ if (behind > 0) {
50
+ parts.push(`↓${behind}`)
51
+ }
52
+ return parts.join(" ")
53
+ }
54
+
55
+ export function buildFileRow(file: ChangedFile, excludedPaths: Set<string>): FileRow {
56
+ const statusSymbol = resolveStatusSymbol(file)
57
+ const statusColor = resolveStatusColor(statusSymbol)
58
+ const pathParts = splitPathParts(file.path)
59
+
60
+ return {
61
+ path: file.path,
62
+ included: !excludedPaths.has(file.path),
63
+ statusSymbol,
64
+ statusColor,
65
+ directory: pathParts.directory,
66
+ filename: pathParts.filename,
67
+ }
68
+ }
69
+
70
+ function splitPathParts(path: string): { directory: string; filename: string } {
71
+ const lastSlash = path.lastIndexOf("/")
72
+ if (lastSlash < 0) {
73
+ return { directory: "", filename: path }
74
+ }
75
+ return {
76
+ directory: path.slice(0, lastSlash + 1),
77
+ filename: path.slice(lastSlash + 1),
78
+ }
79
+ }
80
+
81
+ function resolveStatusSymbol(file: ChangedFile): string {
82
+ if (file.untracked) return "◌"
83
+
84
+ const statuses = [file.indexStatus, file.worktreeStatus]
85
+ if (statuses.includes("D")) return "−"
86
+ if (statuses.includes("A")) return "+"
87
+ if (statuses.includes("R")) return "→"
88
+ if (statuses.includes("M")) return "●"
89
+ return "·"
90
+ }
91
+
92
+ function resolveStatusColor(statusSymbol: string): string {
93
+ if (statusSymbol === "+") return "#3fb950"
94
+ if (statusSymbol === "●") return "#58a6ff"
95
+ if (statusSymbol === "−") return "#ff7b72"
96
+ if (statusSymbol === "→") return "#d2a8ff"
97
+ if (statusSymbol === "◌") return "#d29922"
98
+ return "#9ca3af"
99
+ }