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.
package/src/app.tsx ADDED
@@ -0,0 +1,127 @@
1
+ import { useRenderer, useTerminalDimensions } from "@opentui/react"
2
+
3
+ import type { StageConfig } from "./config"
4
+ import { BranchDialog } from "./ui/components/branch-dialog"
5
+ import { CommitHistoryDialog } from "./ui/components/commit-history-dialog"
6
+ import { useGitTuiController } from "./hooks/use-git-tui-controller"
7
+ import { CommitDialog } from "./ui/components/commit-dialog"
8
+ import { DiffWorkspace } from "./ui/components/diff-workspace"
9
+ import { FooterBar } from "./ui/components/footer-bar"
10
+ import { ShortcutsDialog } from "./ui/components/shortcuts-dialog"
11
+ import { TopBar } from "./ui/components/top-bar"
12
+ import { resolveUiTheme } from "./ui/theme"
13
+
14
+ type AppProps = {
15
+ config: StageConfig
16
+ }
17
+
18
+ export function App({ config }: AppProps) {
19
+ const renderer = useRenderer()
20
+ const { width: terminalWidth = 0, height: terminalHeight = 0 } = useTerminalDimensions()
21
+ const theme = resolveUiTheme(config.ui.theme)
22
+ const controller = useGitTuiController(renderer, config)
23
+ const activeScreen = controller.branchDialogOpen
24
+ ? "branch"
25
+ : controller.commitDialogOpen
26
+ ? "commit"
27
+ : controller.historyDialogOpen
28
+ ? "history"
29
+ : controller.shortcutsDialogOpen
30
+ ? "shortcuts"
31
+ : "main"
32
+
33
+ return (
34
+ <box
35
+ style={{
36
+ width: "100%",
37
+ height: "100%",
38
+ flexDirection: "column",
39
+ }}
40
+ >
41
+ <TopBar
42
+ currentBranch={controller.currentBranch}
43
+ tracking={controller.tracking}
44
+ theme={theme}
45
+ />
46
+
47
+ {activeScreen === "main" ? (
48
+ <DiffWorkspace
49
+ fileRows={controller.fileRows}
50
+ fileIndex={controller.fileIndex}
51
+ selectedFilePath={controller.selectedFilePath}
52
+ focus={controller.focus}
53
+ terminalWidth={terminalWidth}
54
+ terminalHeight={terminalHeight}
55
+ diffText={controller.diffText}
56
+ diffMessage={controller.diffMessage}
57
+ diffFiletype={controller.diffFiletype}
58
+ diffView={config.ui.diffView}
59
+ theme={theme}
60
+ />
61
+ ) : null}
62
+
63
+ {activeScreen === "branch" ? (
64
+ <BranchDialog
65
+ open={true}
66
+ mode={controller.branchDialogMode}
67
+ focus={controller.focus}
68
+ currentBranch={controller.currentBranch}
69
+ branchOptions={controller.branchOptions}
70
+ branchOptionsKey={controller.branchOptionsKey}
71
+ branchIndex={controller.branchIndex}
72
+ onBranchChange={controller.onBranchDialogChange}
73
+ branchStrategyOptions={controller.branchStrategyOptions}
74
+ branchStrategyIndex={controller.branchStrategyIndex}
75
+ onBranchStrategyChange={controller.onBranchStrategyChange}
76
+ branchName={controller.newBranchName}
77
+ branchNameRef={controller.branchNameRef}
78
+ onBranchNameInput={controller.onBranchNameInput}
79
+ theme={theme}
80
+ />
81
+ ) : null}
82
+
83
+ {activeScreen === "commit" ? (
84
+ <CommitDialog
85
+ open={true}
86
+ focus={controller.focus}
87
+ summary={controller.summary}
88
+ descriptionRenderKey={controller.descriptionRenderKey}
89
+ summaryRef={controller.summaryRef}
90
+ descriptionRef={controller.descriptionRef}
91
+ onSummaryInput={controller.onSummaryInput}
92
+ theme={theme}
93
+ />
94
+ ) : null}
95
+
96
+ {activeScreen === "history" ? (
97
+ <CommitHistoryDialog
98
+ open={true}
99
+ mode={controller.historyMode}
100
+ focus={controller.focus}
101
+ currentBranch={controller.currentBranch}
102
+ commitOptions={controller.commitOptions}
103
+ commitIndex={controller.commitIndex}
104
+ onCommitChange={controller.onCommitIndexChange}
105
+ actionOptions={controller.actionOptions}
106
+ actionIndex={controller.actionIndex}
107
+ onActionChange={controller.onActionIndexChange}
108
+ selectedCommitTitle={controller.selectedCommitTitle}
109
+ theme={theme}
110
+ />
111
+ ) : null}
112
+
113
+ {activeScreen === "shortcuts" ? (
114
+ <ShortcutsDialog open={true} aiCommitEnabled={config.ai.enabled} theme={theme} />
115
+ ) : null}
116
+
117
+ <FooterBar
118
+ statusMessage={controller.statusMessage}
119
+ showShortcutsHint={config.ui.showShortcutsHint}
120
+ terminalWidth={terminalWidth}
121
+ fatalError={controller.fatalError}
122
+ isBusy={controller.isBusy}
123
+ theme={theme}
124
+ />
125
+ </box>
126
+ )
127
+ }
@@ -0,0 +1,40 @@
1
+ import { mkdir, writeFile } from "node:fs/promises"
2
+ import { dirname } from "node:path"
3
+
4
+ import type { StageConfig } from "./config"
5
+
6
+ export async function ensureUserConfigFile(configPath: string, defaults: StageConfig): Promise<void> {
7
+ await mkdir(dirname(configPath), { recursive: true })
8
+ try {
9
+ await writeFile(configPath, createDefaultConfigToml(defaults), { flag: "wx" })
10
+ } catch (error) {
11
+ const err = error as NodeJS.ErrnoException
12
+ if (err.code !== "EEXIST") throw error
13
+ }
14
+ }
15
+
16
+ function createDefaultConfigToml(defaults: StageConfig): string {
17
+ return [
18
+ "[ui]",
19
+ `diff_view = "${defaults.ui.diffView}"`,
20
+ `theme = "${defaults.ui.theme}"`,
21
+ `hide_whitespace_changes = ${defaults.ui.hideWhitespaceChanges}`,
22
+ `show_shortcuts_hint = ${defaults.ui.showShortcutsHint}`,
23
+ "",
24
+ "[history]",
25
+ `limit = ${defaults.history.limit}`,
26
+ "",
27
+ "[git]",
28
+ `auto_stage_on_commit = ${defaults.git.autoStageOnCommit}`,
29
+ "",
30
+ "[ai]",
31
+ `enabled = ${defaults.ai.enabled}`,
32
+ `provider = "${defaults.ai.provider}"`,
33
+ 'api_key = ""',
34
+ `model = "${defaults.ai.model}"`,
35
+ `reasoning_effort = "${defaults.ai.reasoningEffort}"`,
36
+ `max_files = ${defaults.ai.maxFiles}`,
37
+ `max_chars_per_file = ${defaults.ai.maxCharsPerFile}`,
38
+ "",
39
+ ].join("\n")
40
+ }
package/src/config.ts ADDED
@@ -0,0 +1,283 @@
1
+ import { homedir } from "node:os"
2
+ import { join, resolve } from "node:path"
3
+
4
+ import { ensureUserConfigFile } from "./config-file"
5
+
6
+ export type StageConfig = {
7
+ ui: {
8
+ diffView: "unified" | "split"
9
+ theme: "auto" | "dark" | "light"
10
+ hideWhitespaceChanges: boolean
11
+ showShortcutsHint: boolean
12
+ }
13
+ history: {
14
+ limit: number
15
+ }
16
+ git: {
17
+ autoStageOnCommit: boolean
18
+ }
19
+ ai: {
20
+ enabled: boolean
21
+ provider: "cerebras"
22
+ apiKey: string
23
+ model: string
24
+ reasoningEffort: "low" | "medium" | "high"
25
+ maxFiles: number
26
+ maxCharsPerFile: number
27
+ }
28
+ }
29
+
30
+ export type ResolvedStageConfig = {
31
+ config: StageConfig
32
+ source: string
33
+ }
34
+
35
+ export const DEFAULT_STAGE_CONFIG: StageConfig = {
36
+ ui: {
37
+ diffView: "unified",
38
+ theme: "auto",
39
+ hideWhitespaceChanges: true,
40
+ showShortcutsHint: true,
41
+ },
42
+ history: {
43
+ limit: 200,
44
+ },
45
+ git: {
46
+ autoStageOnCommit: true,
47
+ },
48
+ ai: {
49
+ enabled: false,
50
+ provider: "cerebras",
51
+ apiKey: "",
52
+ model: "gpt-oss-120b",
53
+ reasoningEffort: "low",
54
+ maxFiles: 32,
55
+ maxCharsPerFile: 4000,
56
+ },
57
+ }
58
+
59
+ export async function loadStageConfig(cwd: string): Promise<ResolvedStageConfig> {
60
+ const envPath = process.env.STAGE_CONFIG?.trim()
61
+ if (envPath) {
62
+ const explicitPath = resolve(cwd, envPath)
63
+ const file = Bun.file(explicitPath)
64
+ if (!(await file.exists())) {
65
+ throw new Error(`Config file from STAGE_CONFIG was not found: ${explicitPath}`)
66
+ }
67
+ const raw = await file.text()
68
+ return {
69
+ config: parseStageConfigToml(raw, explicitPath),
70
+ source: explicitPath,
71
+ }
72
+ }
73
+
74
+ const localPath = resolve(cwd, ".stage-manager.toml")
75
+ const localFile = Bun.file(localPath)
76
+ if (await localFile.exists()) {
77
+ const raw = await localFile.text()
78
+ return {
79
+ config: parseStageConfigToml(raw, localPath),
80
+ source: localPath,
81
+ }
82
+ }
83
+
84
+ const userPath = getUserConfigPath()
85
+ const userFile = Bun.file(userPath)
86
+ if (await userFile.exists()) {
87
+ const raw = await userFile.text()
88
+ return {
89
+ config: parseStageConfigToml(raw, userPath),
90
+ source: userPath,
91
+ }
92
+ }
93
+
94
+ await ensureUserConfigFile(userPath, DEFAULT_STAGE_CONFIG)
95
+ const createdRaw = await Bun.file(userPath).text()
96
+ return {
97
+ config: parseStageConfigToml(createdRaw, userPath),
98
+ source: userPath,
99
+ }
100
+ }
101
+
102
+ export function getUserConfigPath(): string {
103
+ const xdg = process.env.XDG_CONFIG_HOME?.trim()
104
+ const configRoot = xdg ? xdg : join(homedir(), ".config")
105
+ return join(configRoot, "stage-manager", "config.toml")
106
+ }
107
+
108
+ function parseStageConfigToml(raw: string, sourcePath: string): StageConfig {
109
+ let parsed: unknown
110
+ try {
111
+ parsed = Bun.TOML.parse(raw)
112
+ } catch (error) {
113
+ const message = error instanceof Error ? error.message : String(error)
114
+ throw new Error(`Invalid TOML in ${sourcePath}: ${message}`)
115
+ }
116
+
117
+ const root = asRecord(parsed, `Config root in ${sourcePath}`)
118
+ assertNoUnknownKeys(root, ["ui", "history", "git", "ai"], `Config root in ${sourcePath}`)
119
+
120
+ const config = cloneConfig(DEFAULT_STAGE_CONFIG)
121
+
122
+ if (root.ui !== undefined) {
123
+ const ui = asRecord(root.ui, `[ui] in ${sourcePath}`)
124
+ assertNoUnknownKeys(ui, ["diff_view", "theme", "hide_whitespace_changes", "show_shortcuts_hint"], `[ui] in ${sourcePath}`)
125
+
126
+ if (ui.diff_view !== undefined) {
127
+ if (ui.diff_view !== "unified" && ui.diff_view !== "split") {
128
+ throw new Error(`Invalid value for ui.diff_view in ${sourcePath}. Expected "unified" or "split".`)
129
+ }
130
+ config.ui.diffView = ui.diff_view
131
+ }
132
+
133
+ if (ui.theme !== undefined) {
134
+ if (ui.theme !== "auto" && ui.theme !== "dark" && ui.theme !== "light") {
135
+ throw new Error(`Invalid value for ui.theme in ${sourcePath}. Expected "auto", "dark", or "light".`)
136
+ }
137
+ config.ui.theme = ui.theme
138
+ }
139
+
140
+ if (ui.hide_whitespace_changes !== undefined) {
141
+ config.ui.hideWhitespaceChanges = asBoolean(ui.hide_whitespace_changes, `ui.hide_whitespace_changes in ${sourcePath}`)
142
+ }
143
+
144
+ if (ui.show_shortcuts_hint !== undefined) {
145
+ config.ui.showShortcutsHint = asBoolean(ui.show_shortcuts_hint, `ui.show_shortcuts_hint in ${sourcePath}`)
146
+ }
147
+ }
148
+
149
+ if (root.history !== undefined) {
150
+ const history = asRecord(root.history, `[history] in ${sourcePath}`)
151
+ assertNoUnknownKeys(history, ["limit"], `[history] in ${sourcePath}`)
152
+
153
+ if (history.limit !== undefined) {
154
+ const limit = asInteger(history.limit, `history.limit in ${sourcePath}`)
155
+ if (limit <= 0) {
156
+ throw new Error(`history.limit in ${sourcePath} must be greater than 0.`)
157
+ }
158
+ config.history.limit = limit
159
+ }
160
+ }
161
+
162
+ if (root.git !== undefined) {
163
+ const git = asRecord(root.git, `[git] in ${sourcePath}`)
164
+ assertNoUnknownKeys(git, ["auto_stage_on_commit"], `[git] in ${sourcePath}`)
165
+
166
+ if (git.auto_stage_on_commit !== undefined) {
167
+ config.git.autoStageOnCommit = asBoolean(git.auto_stage_on_commit, `git.auto_stage_on_commit in ${sourcePath}`)
168
+ }
169
+ }
170
+
171
+ if (root.ai !== undefined) {
172
+ const ai = asRecord(root.ai, `[ai] in ${sourcePath}`)
173
+ assertNoUnknownKeys(ai, ["enabled", "provider", "api_key", "model", "reasoning_effort", "max_files", "max_chars_per_file"], `[ai] in ${sourcePath}`)
174
+
175
+ if (ai.enabled !== undefined) {
176
+ config.ai.enabled = asBoolean(ai.enabled, `ai.enabled in ${sourcePath}`)
177
+ }
178
+
179
+ if (ai.provider !== undefined) {
180
+ if (ai.provider !== "cerebras") {
181
+ throw new Error(`Invalid value for ai.provider in ${sourcePath}. Expected "cerebras".`)
182
+ }
183
+ config.ai.provider = ai.provider
184
+ }
185
+
186
+ if (ai.api_key !== undefined) {
187
+ if (typeof ai.api_key !== "string") {
188
+ throw new Error(`ai.api_key in ${sourcePath} must be a string.`)
189
+ }
190
+ config.ai.apiKey = ai.api_key.trim()
191
+ }
192
+
193
+ if (ai.model !== undefined) {
194
+ if (typeof ai.model !== "string" || !ai.model.trim()) {
195
+ throw new Error(`ai.model in ${sourcePath} must be a non-empty string.`)
196
+ }
197
+ config.ai.model = ai.model.trim()
198
+ }
199
+
200
+ if (ai.reasoning_effort !== undefined) {
201
+ if (ai.reasoning_effort !== "low" && ai.reasoning_effort !== "medium" && ai.reasoning_effort !== "high") {
202
+ throw new Error(`Invalid value for ai.reasoning_effort in ${sourcePath}. Expected "low", "medium", or "high".`)
203
+ }
204
+ config.ai.reasoningEffort = ai.reasoning_effort
205
+ }
206
+
207
+ if (ai.max_files !== undefined) {
208
+ const maxFiles = asInteger(ai.max_files, `ai.max_files in ${sourcePath}`)
209
+ if (maxFiles <= 0) {
210
+ throw new Error(`ai.max_files in ${sourcePath} must be greater than 0.`)
211
+ }
212
+ config.ai.maxFiles = maxFiles
213
+ }
214
+
215
+ if (ai.max_chars_per_file !== undefined) {
216
+ const maxCharsPerFile = asInteger(ai.max_chars_per_file, `ai.max_chars_per_file in ${sourcePath}`)
217
+ if (maxCharsPerFile <= 0) {
218
+ throw new Error(`ai.max_chars_per_file in ${sourcePath} must be greater than 0.`)
219
+ }
220
+ config.ai.maxCharsPerFile = maxCharsPerFile
221
+ }
222
+ }
223
+
224
+ if (config.ai.enabled && !config.ai.apiKey) {
225
+ throw new Error(`ai.enabled is true in ${sourcePath}, but ai.api_key is empty.`)
226
+ }
227
+
228
+ return config
229
+ }
230
+
231
+ function cloneConfig(config: StageConfig): StageConfig {
232
+ return {
233
+ ui: {
234
+ diffView: config.ui.diffView,
235
+ theme: config.ui.theme,
236
+ hideWhitespaceChanges: config.ui.hideWhitespaceChanges,
237
+ showShortcutsHint: config.ui.showShortcutsHint,
238
+ },
239
+ history: {
240
+ limit: config.history.limit,
241
+ },
242
+ git: {
243
+ autoStageOnCommit: config.git.autoStageOnCommit,
244
+ },
245
+ ai: {
246
+ enabled: config.ai.enabled,
247
+ provider: config.ai.provider,
248
+ apiKey: config.ai.apiKey,
249
+ model: config.ai.model,
250
+ reasoningEffort: config.ai.reasoningEffort,
251
+ maxFiles: config.ai.maxFiles,
252
+ maxCharsPerFile: config.ai.maxCharsPerFile,
253
+ },
254
+ }
255
+ }
256
+
257
+ function asRecord(value: unknown, label: string): Record<string, unknown> {
258
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
259
+ throw new Error(`${label} must be a table/object.`)
260
+ }
261
+ return value as Record<string, unknown>
262
+ }
263
+
264
+ function asBoolean(value: unknown, label: string): boolean {
265
+ if (typeof value !== "boolean") {
266
+ throw new Error(`${label} must be a boolean.`)
267
+ }
268
+ return value
269
+ }
270
+
271
+ function asInteger(value: unknown, label: string): number {
272
+ if (typeof value !== "number" || !Number.isInteger(value)) {
273
+ throw new Error(`${label} must be an integer.`)
274
+ }
275
+ return value
276
+ }
277
+
278
+ function assertNoUnknownKeys(record: Record<string, unknown>, knownKeys: string[], label: string): void {
279
+ const unknown = Object.keys(record).filter((key) => !knownKeys.includes(key))
280
+ if (unknown.length > 0) {
281
+ throw new Error(`${label} has unsupported keys: ${unknown.join(", ")}`)
282
+ }
283
+ }
@@ -0,0 +1,13 @@
1
+ export function normalizeBranchName(input: string): string {
2
+ const normalized = input
3
+ .trim()
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9/]+/g, "-")
6
+ .replace(/-+/g, "-")
7
+ .replace(/\/+/g, "/")
8
+ .replace(/\/-/g, "/")
9
+ .replace(/-\//g, "/")
10
+ .replace(/^[-/]+|[-/]+$/g, "")
11
+
12
+ return normalized
13
+ }
@@ -0,0 +1,25 @@
1
+ export type GitCommandResult = {
2
+ code: number
3
+ stdout: string
4
+ stderr: string
5
+ }
6
+
7
+ export async function runGitRaw(cwd: string, args: string[]): Promise<GitCommandResult> {
8
+ const proc = Bun.spawn(["git", ...args], {
9
+ cwd,
10
+ stdout: "pipe",
11
+ stderr: "pipe",
12
+ })
13
+
14
+ const [stdout, stderr, code] = await Promise.all([
15
+ new Response(proc.stdout).text(),
16
+ new Response(proc.stderr).text(),
17
+ proc.exited,
18
+ ])
19
+
20
+ return {
21
+ code,
22
+ stdout: stdout.trimEnd(),
23
+ stderr: stderr.trimEnd(),
24
+ }
25
+ }
@@ -0,0 +1,103 @@
1
+ import type { ChangedFile } from "./git"
2
+
3
+ const STATUS_NAME: Record<string, string> = {
4
+ " ": "clean",
5
+ M: "modified",
6
+ A: "added",
7
+ D: "deleted",
8
+ R: "renamed",
9
+ C: "copied",
10
+ U: "unmerged",
11
+ "?": "untracked",
12
+ }
13
+
14
+ export function parseBranchLine(line: string): {
15
+ branch: string
16
+ upstream: string | null
17
+ ahead: number
18
+ behind: number
19
+ } {
20
+ if (!line.startsWith("##")) {
21
+ return { branch: "unknown", upstream: null, ahead: 0, behind: 0 }
22
+ }
23
+
24
+ const raw = line.slice(2).trim()
25
+ if (raw.startsWith("No commits yet on ")) {
26
+ return {
27
+ branch: raw.replace("No commits yet on ", "").trim(),
28
+ upstream: null,
29
+ ahead: 0,
30
+ behind: 0,
31
+ }
32
+ }
33
+
34
+ if (raw.startsWith("HEAD")) {
35
+ return { branch: "detached", upstream: null, ahead: 0, behind: 0 }
36
+ }
37
+
38
+ const [localPart, trackingPart] = raw.split("...")
39
+ const branch = (localPart ?? "unknown").trim()
40
+ let upstream: string | null = null
41
+ let ahead = 0
42
+ let behind = 0
43
+
44
+ if (trackingPart) {
45
+ const match = trackingPart.match(/^([^\s]+)(?: \[(.+)\])?$/)
46
+ if (match) {
47
+ upstream = match[1] ?? null
48
+ const tracking = match[2] ?? ""
49
+ for (const token of tracking.split(",").map((item) => item.trim())) {
50
+ if (token.startsWith("ahead ")) {
51
+ ahead = Number(token.slice("ahead ".length)) || 0
52
+ } else if (token.startsWith("behind ")) {
53
+ behind = Number(token.slice("behind ".length)) || 0
54
+ }
55
+ }
56
+ } else {
57
+ upstream = trackingPart.trim() || null
58
+ }
59
+ }
60
+
61
+ return { branch, upstream, ahead, behind }
62
+ }
63
+
64
+ export function parseChangedFiles(lines: string[]): ChangedFile[] {
65
+ return lines
66
+ .map((line) => {
67
+ const indexStatus = line[0] ?? " "
68
+ const worktreeStatus = line[1] ?? " "
69
+ const pathPart = line.slice(3).trim()
70
+ const path = pathPart.includes(" -> ") ? (pathPart.split(" -> ").pop() ?? "").trim() : pathPart
71
+ if (!path) return null
72
+
73
+ const untracked = indexStatus === "?" && worktreeStatus === "?"
74
+ const staged = !untracked && indexStatus !== " "
75
+ const unstaged = !untracked && worktreeStatus !== " "
76
+
77
+ return {
78
+ path,
79
+ indexStatus,
80
+ worktreeStatus,
81
+ staged,
82
+ unstaged,
83
+ untracked,
84
+ statusLabel: buildStatusLabel(indexStatus, worktreeStatus),
85
+ } satisfies ChangedFile
86
+ })
87
+ .filter((file): file is ChangedFile => Boolean(file))
88
+ }
89
+
90
+ function buildStatusLabel(indexStatus: string, worktreeStatus: string): string {
91
+ if (indexStatus === "?" && worktreeStatus === "?") {
92
+ return "untracked"
93
+ }
94
+
95
+ const parts: string[] = []
96
+ if (indexStatus !== " ") {
97
+ parts.push(`staged ${STATUS_NAME[indexStatus] ?? indexStatus}`)
98
+ }
99
+ if (worktreeStatus !== " ") {
100
+ parts.push(`unstaged ${STATUS_NAME[worktreeStatus] ?? worktreeStatus}`)
101
+ }
102
+ return parts.join(", ") || "clean"
103
+ }