@prover-coder-ai/docker-git 1.0.16 → 1.0.18

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.
Files changed (65) hide show
  1. package/.package.json.release.bak +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +12 -7
  4. package/dist/main.js +24 -7
  5. package/dist/main.js.map +1 -1
  6. package/dist/src/docker-git/cli/parser-auth.js +32 -12
  7. package/dist/src/docker-git/cli/parser.js +1 -1
  8. package/dist/src/docker-git/cli/usage.js +4 -3
  9. package/dist/src/docker-git/menu-actions.js +23 -7
  10. package/dist/src/docker-git/menu-auth-data.js +90 -0
  11. package/dist/src/docker-git/menu-auth-helpers.js +20 -0
  12. package/dist/src/docker-git/menu-auth.js +159 -0
  13. package/dist/src/docker-git/menu-buffer-input.js +9 -0
  14. package/dist/src/docker-git/menu-create.js +5 -9
  15. package/dist/src/docker-git/menu-input-handler.js +70 -28
  16. package/dist/src/docker-git/menu-input-utils.js +47 -0
  17. package/dist/src/docker-git/menu-labeled-env.js +33 -0
  18. package/dist/src/docker-git/menu-project-auth-claude.js +43 -0
  19. package/dist/src/docker-git/menu-project-auth-data.js +165 -0
  20. package/dist/src/docker-git/menu-project-auth.js +124 -0
  21. package/dist/src/docker-git/menu-render-auth.js +45 -0
  22. package/dist/src/docker-git/menu-render-common.js +26 -0
  23. package/dist/src/docker-git/menu-render-layout.js +14 -0
  24. package/dist/src/docker-git/menu-render-project-auth.js +37 -0
  25. package/dist/src/docker-git/menu-render-select.js +11 -4
  26. package/dist/src/docker-git/menu-render.js +4 -13
  27. package/dist/src/docker-git/menu-select-actions.js +66 -0
  28. package/dist/src/docker-git/menu-select-view.js +15 -0
  29. package/dist/src/docker-git/menu-select.js +11 -75
  30. package/dist/src/docker-git/menu-shared.js +86 -17
  31. package/dist/src/docker-git/menu-types.js +3 -1
  32. package/dist/src/docker-git/menu.js +13 -1
  33. package/dist/src/docker-git/program.js +3 -3
  34. package/package.json +1 -1
  35. package/src/docker-git/cli/parser-auth.ts +46 -16
  36. package/src/docker-git/cli/parser-mcp-playwright.ts +0 -1
  37. package/src/docker-git/cli/parser.ts +1 -1
  38. package/src/docker-git/cli/usage.ts +4 -3
  39. package/src/docker-git/menu-actions.ts +31 -12
  40. package/src/docker-git/menu-auth-data.ts +184 -0
  41. package/src/docker-git/menu-auth-helpers.ts +30 -0
  42. package/src/docker-git/menu-auth.ts +311 -0
  43. package/src/docker-git/menu-buffer-input.ts +18 -0
  44. package/src/docker-git/menu-create.ts +5 -11
  45. package/src/docker-git/menu-input-handler.ts +104 -28
  46. package/src/docker-git/menu-input-utils.ts +85 -0
  47. package/src/docker-git/menu-labeled-env.ts +37 -0
  48. package/src/docker-git/menu-project-auth-claude.ts +70 -0
  49. package/src/docker-git/menu-project-auth-data.ts +292 -0
  50. package/src/docker-git/menu-project-auth.ts +271 -0
  51. package/src/docker-git/menu-render-auth.ts +65 -0
  52. package/src/docker-git/menu-render-common.ts +67 -0
  53. package/src/docker-git/menu-render-layout.ts +30 -0
  54. package/src/docker-git/menu-render-project-auth.ts +70 -0
  55. package/src/docker-git/menu-render-select.ts +12 -2
  56. package/src/docker-git/menu-render.ts +5 -29
  57. package/src/docker-git/menu-select-actions.ts +150 -0
  58. package/src/docker-git/menu-select-load.ts +1 -1
  59. package/src/docker-git/menu-select-view.ts +25 -0
  60. package/src/docker-git/menu-select.ts +21 -167
  61. package/src/docker-git/menu-shared.ts +135 -20
  62. package/src/docker-git/menu-types.ts +70 -3
  63. package/src/docker-git/menu.ts +26 -1
  64. package/src/docker-git/program.ts +10 -4
  65. package/tests/docker-git/entrypoint-auth.test.ts +1 -1
@@ -0,0 +1,150 @@
1
+ import { runDockerComposeDown } from "@effect-template/lib/shell/docker"
2
+ import type { AppError } from "@effect-template/lib/usecases/errors"
3
+ import { renderError } from "@effect-template/lib/usecases/errors"
4
+ import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright"
5
+ import {
6
+ connectProjectSshWithUp,
7
+ deleteDockerGitProject,
8
+ listRunningProjectItems,
9
+ type ProjectItem
10
+ } from "@effect-template/lib/usecases/projects"
11
+ import { Effect, pipe } from "effect"
12
+
13
+ import { openProjectAuthMenu } from "./menu-project-auth.js"
14
+ import { buildConnectEffect } from "./menu-select-connect.js"
15
+ import { loadRuntimeByProject } from "./menu-select-runtime.js"
16
+ import { startSelectView } from "./menu-select-view.js"
17
+ import {
18
+ pauseOnError,
19
+ resetToMenu,
20
+ resumeSshWithSkipInputs,
21
+ resumeWithSkipInputs,
22
+ withSuspendedTui
23
+ } from "./menu-shared.js"
24
+ import type { MenuRunner, MenuViewContext } from "./menu-types.js"
25
+
26
+ export type SelectContext = MenuViewContext & {
27
+ readonly activeDir: string | null
28
+ readonly runner: MenuRunner
29
+ readonly setSshActive: (active: boolean) => void
30
+ readonly setSkipInputs: (update: (value: number) => number) => void
31
+ }
32
+
33
+ export const runConnectSelection = (
34
+ selected: ProjectItem,
35
+ context: SelectContext,
36
+ enableMcpPlaywright: boolean
37
+ ) => {
38
+ context.setMessage(
39
+ enableMcpPlaywright
40
+ ? `Enabling Playwright MCP for ${selected.displayName}, then connecting...`
41
+ : `Connecting to ${selected.displayName}...`
42
+ )
43
+ context.setSshActive(true)
44
+ context.runner.runEffect(
45
+ pipe(
46
+ withSuspendedTui(
47
+ buildConnectEffect(selected, enableMcpPlaywright, {
48
+ connectWithUp: (item) =>
49
+ connectProjectSshWithUp(item).pipe(
50
+ Effect.mapError((error): AppError => error)
51
+ ),
52
+ enableMcpPlaywright: (projectDir) =>
53
+ mcpPlaywrightUp({ _tag: "McpPlaywrightUp", projectDir, runUp: false }).pipe(
54
+ Effect.asVoid,
55
+ Effect.mapError((error): AppError => error)
56
+ )
57
+ }),
58
+ {
59
+ onError: pauseOnError(renderError),
60
+ onResume: resumeSshWithSkipInputs(context)
61
+ }
62
+ ),
63
+ Effect.tap(() =>
64
+ Effect.sync(() => {
65
+ context.setMessage("SSH session ended. Press Esc to return to the menu.")
66
+ })
67
+ ),
68
+ Effect.asVoid
69
+ )
70
+ )
71
+ }
72
+
73
+ export const runDownSelection = (selected: ProjectItem, context: SelectContext) => {
74
+ context.setMessage(`Stopping ${selected.displayName}...`)
75
+ context.runner.runEffect(
76
+ withSuspendedTui(
77
+ pipe(
78
+ runDockerComposeDown(selected.projectDir),
79
+ Effect.zipRight(listRunningProjectItems),
80
+ Effect.flatMap((items) =>
81
+ pipe(
82
+ loadRuntimeByProject(items),
83
+ Effect.map((runtimeByProject) => ({ items, runtimeByProject }))
84
+ )
85
+ ),
86
+ Effect.tap(({ items, runtimeByProject }) =>
87
+ Effect.sync(() => {
88
+ if (items.length === 0) {
89
+ resetToMenu(context)
90
+ context.setMessage("No running docker-git containers.")
91
+ return
92
+ }
93
+ startSelectView(items, "Down", context, runtimeByProject)
94
+ context.setMessage("Container stopped. Select another to stop, or Esc to return.")
95
+ })
96
+ ),
97
+ Effect.asVoid
98
+ ),
99
+ {
100
+ onError: pauseOnError(renderError),
101
+ onResume: resumeWithSkipInputs(context)
102
+ }
103
+ )
104
+ )
105
+ }
106
+
107
+ export const runInfoSelection = (selected: ProjectItem, context: SelectContext) => {
108
+ context.setMessage(`Details for ${selected.displayName} are shown on the right. Press Esc to return to the menu.`)
109
+ }
110
+
111
+ export const runAuthSelection = (selected: ProjectItem, context: SelectContext) => {
112
+ openProjectAuthMenu({
113
+ project: selected,
114
+ runner: context.runner,
115
+ setView: context.setView,
116
+ setMessage: context.setMessage,
117
+ setActiveDir: context.setActiveDir
118
+ })
119
+ }
120
+
121
+ export const runDeleteSelection = (selected: ProjectItem, context: SelectContext) => {
122
+ context.setMessage(`Deleting ${selected.displayName}...`)
123
+ context.runner.runEffect(
124
+ pipe(
125
+ withSuspendedTui(
126
+ deleteDockerGitProject(selected).pipe(
127
+ Effect.tap(() =>
128
+ Effect.sync(() => {
129
+ if (context.activeDir === selected.projectDir) {
130
+ context.setActiveDir(null)
131
+ }
132
+ context.setView({ _tag: "Menu" })
133
+ })
134
+ ),
135
+ Effect.asVoid
136
+ ),
137
+ {
138
+ onError: pauseOnError(renderError),
139
+ onResume: resumeWithSkipInputs(context)
140
+ }
141
+ ),
142
+ Effect.tap(() =>
143
+ Effect.sync(() => {
144
+ context.setMessage("Project deleted.")
145
+ })
146
+ ),
147
+ Effect.asVoid
148
+ )
149
+ )
150
+ }
@@ -7,7 +7,7 @@ import type { MenuEnv, MenuViewContext } from "./menu-types.js"
7
7
 
8
8
  export const loadSelectView = <E>(
9
9
  effect: Effect.Effect<ReadonlyArray<ProjectItem>, E, MenuEnv>,
10
- purpose: "Connect" | "Down" | "Info" | "Delete",
10
+ purpose: "Connect" | "Down" | "Info" | "Delete" | "Auth",
11
11
  context: Pick<MenuViewContext, "setView" | "setMessage">
12
12
  ): Effect.Effect<void, E, MenuEnv> =>
13
13
  pipe(
@@ -0,0 +1,25 @@
1
+ import type { ProjectItem } from "@effect-template/lib/usecases/projects"
2
+
3
+ import { sortItemsByLaunchTime } from "./menu-select-order.js"
4
+ import type { MenuViewContext, SelectProjectRuntime } from "./menu-types.js"
5
+
6
+ const emptyRuntimeByProject = (): Readonly<Record<string, SelectProjectRuntime>> => ({})
7
+
8
+ export const startSelectView = (
9
+ items: ReadonlyArray<ProjectItem>,
10
+ purpose: "Connect" | "Down" | "Info" | "Delete" | "Auth",
11
+ context: Pick<MenuViewContext, "setView" | "setMessage">,
12
+ runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = emptyRuntimeByProject()
13
+ ) => {
14
+ const sortedItems = sortItemsByLaunchTime(items, runtimeByProject)
15
+ context.setMessage(null)
16
+ context.setView({
17
+ _tag: "SelectProject",
18
+ purpose,
19
+ items: sortedItems,
20
+ runtimeByProject,
21
+ selected: 0,
22
+ confirmDelete: false,
23
+ connectEnableMcpPlaywright: false
24
+ })
25
+ }
@@ -1,53 +1,19 @@
1
- import { runDockerComposeDown } from "@effect-template/lib/shell/docker"
2
- import type { AppError } from "@effect-template/lib/usecases/errors"
3
- import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright"
4
- import {
5
- connectProjectSshWithUp,
6
- deleteDockerGitProject,
7
- listRunningProjectItems,
8
- type ProjectItem
9
- } from "@effect-template/lib/usecases/projects"
10
- import { Effect, Match, pipe } from "effect"
11
- import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js"
12
- import { sortItemsByLaunchTime } from "./menu-select-order.js"
13
- import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js"
14
- import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js"
15
- import type {
16
- MenuEnv,
17
- MenuKeyInput,
18
- MenuRunner,
19
- MenuViewContext,
20
- SelectProjectRuntime,
21
- ViewState
22
- } from "./menu-types.js"
1
+ import { Match } from "effect"
23
2
 
24
- type SelectContext = MenuViewContext & {
25
- readonly activeDir: string | null
26
- readonly runner: MenuRunner
27
- readonly setSshActive: (active: boolean) => void
28
- readonly setSkipInputs: (update: (value: number) => number) => void
29
- }
3
+ import {
4
+ runAuthSelection,
5
+ runConnectSelection,
6
+ runDeleteSelection,
7
+ runDownSelection,
8
+ runInfoSelection,
9
+ type SelectContext
10
+ } from "./menu-select-actions.js"
11
+ import { isConnectMcpToggleInput } from "./menu-select-connect.js"
12
+ import { runtimeForSelection } from "./menu-select-runtime.js"
13
+ import { resetToMenu } from "./menu-shared.js"
14
+ import type { MenuKeyInput, ViewState } from "./menu-types.js"
30
15
 
31
- const emptyRuntimeByProject = (): Readonly<Record<string, SelectProjectRuntime>> => ({})
32
-
33
- export const startSelectView = (
34
- items: ReadonlyArray<ProjectItem>,
35
- purpose: "Connect" | "Down" | "Info" | "Delete",
36
- context: Pick<SelectContext, "setView" | "setMessage">,
37
- runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = emptyRuntimeByProject()
38
- ) => {
39
- const sortedItems = sortItemsByLaunchTime(items, runtimeByProject)
40
- context.setMessage(null)
41
- context.setView({
42
- _tag: "SelectProject",
43
- purpose,
44
- items: sortedItems,
45
- runtimeByProject,
46
- selected: 0,
47
- confirmDelete: false,
48
- connectEnableMcpPlaywright: false
49
- })
50
- }
16
+ export { startSelectView } from "./menu-select-view.js"
51
17
 
52
18
  const clampIndex = (value: number, size: number): number => {
53
19
  if (size <= 0) {
@@ -123,122 +89,8 @@ const handleSelectNavigation = (
123
89
  return false
124
90
  }
125
91
 
126
- const runWithSuspendedTui = (
127
- context: Pick<SelectContext, "runner" | "setMessage" | "setSkipInputs">,
128
- effect: Effect.Effect<void, AppError, MenuEnv>,
129
- onResume: () => void,
130
- doneMessage: string
131
- ) => {
132
- context.runner.runEffect(
133
- pipe(
134
- Effect.sync(suspendTui),
135
- Effect.zipRight(effect),
136
- Effect.ensuring(
137
- Effect.sync(() => {
138
- resumeTui()
139
- onResume()
140
- context.setSkipInputs(() => 2)
141
- })
142
- ),
143
- Effect.tap(() =>
144
- Effect.sync(() => {
145
- context.setMessage(doneMessage)
146
- })
147
- )
148
- )
149
- )
150
- }
151
-
152
- const runConnectSelection = (
153
- selected: ProjectItem,
154
- context: SelectContext,
155
- enableMcpPlaywright: boolean
156
- ) => {
157
- context.setMessage(
158
- enableMcpPlaywright
159
- ? `Enabling Playwright MCP for ${selected.displayName}, then connecting...`
160
- : `Connecting to ${selected.displayName}...`
161
- )
162
- context.setSshActive(true)
163
- runWithSuspendedTui(
164
- context,
165
- buildConnectEffect(selected, enableMcpPlaywright, {
166
- connectWithUp: (item) =>
167
- connectProjectSshWithUp(item).pipe(
168
- Effect.mapError((error): AppError => error)
169
- ),
170
- enableMcpPlaywright: (projectDir) =>
171
- mcpPlaywrightUp({ _tag: "McpPlaywrightUp", projectDir, runUp: false }).pipe(
172
- Effect.asVoid,
173
- Effect.mapError((error): AppError => error)
174
- )
175
- }),
176
- () => {
177
- context.setSshActive(false)
178
- },
179
- "SSH session ended. Press Esc to return to the menu."
180
- )
181
- }
182
-
183
- const runDownSelection = (selected: ProjectItem, context: SelectContext) => {
184
- context.setMessage(`Stopping ${selected.displayName}...`)
185
- context.runner.runEffect(
186
- pipe(
187
- Effect.sync(suspendTui),
188
- Effect.zipRight(runDockerComposeDown(selected.projectDir)),
189
- Effect.zipRight(listRunningProjectItems),
190
- Effect.flatMap((items) =>
191
- pipe(
192
- loadRuntimeByProject(items),
193
- Effect.map((runtimeByProject) => ({ items, runtimeByProject }))
194
- )
195
- ),
196
- Effect.tap(({ items, runtimeByProject }) =>
197
- Effect.sync(() => {
198
- if (items.length === 0) {
199
- resetToMenu(context)
200
- context.setMessage("No running docker-git containers.")
201
- return
202
- }
203
- startSelectView(items, "Down", context, runtimeByProject)
204
- context.setMessage("Container stopped. Select another to stop, or Esc to return.")
205
- })
206
- ),
207
- Effect.ensuring(
208
- Effect.sync(() => {
209
- resumeTui()
210
- context.setSkipInputs(() => 2)
211
- })
212
- ),
213
- Effect.asVoid
214
- )
215
- )
216
- }
217
-
218
- const runInfoSelection = (selected: ProjectItem, context: SelectContext) => {
219
- context.setMessage(`Details for ${selected.displayName} are shown on the right. Press Esc to return to the menu.`)
220
- }
221
-
222
- const runDeleteSelection = (selected: ProjectItem, context: SelectContext) => {
223
- context.setMessage(`Deleting ${selected.displayName}...`)
224
- runWithSuspendedTui(
225
- context,
226
- deleteDockerGitProject(selected).pipe(
227
- Effect.tap(() =>
228
- Effect.sync(() => {
229
- if (context.activeDir === selected.projectDir) {
230
- context.setActiveDir(null)
231
- }
232
- context.setView({ _tag: "Menu" })
233
- })
234
- )
235
- ),
236
- () => {
237
- // Only return to menu on success (see Effect.tap above).
238
- },
239
- "Project deleted."
240
- )
241
- }
92
+ const formatSshSessionsLabel = (sshSessions: number): string =>
93
+ sshSessions === 1 ? "1 active SSH session" : `${sshSessions} active SSH sessions`
242
94
 
243
95
  const handleSelectReturn = (
244
96
  view: Extract<ViewState, { readonly _tag: "SelectProject" }>,
@@ -251,15 +103,17 @@ const handleSelectReturn = (
251
103
  return
252
104
  }
253
105
  const selectedRuntime = runtimeForSelection(view, selected)
254
- const sshSessionsLabel = selectedRuntime.sshSessions === 1
255
- ? "1 active SSH session"
256
- : `${selectedRuntime.sshSessions} active SSH sessions`
106
+ const sshSessionsLabel = formatSshSessionsLabel(selectedRuntime.sshSessions)
257
107
 
258
108
  Match.value(view.purpose).pipe(
259
109
  Match.when("Connect", () => {
260
110
  context.setActiveDir(selected.projectDir)
261
111
  runConnectSelection(selected, context, view.connectEnableMcpPlaywright)
262
112
  }),
113
+ Match.when("Auth", () => {
114
+ context.setActiveDir(selected.projectDir)
115
+ runAuthSelection(selected, context)
116
+ }),
263
117
  Match.when("Down", () => {
264
118
  if (selectedRuntime.sshSessions > 0 && !view.confirmDelete) {
265
119
  context.setMessage(
@@ -1,5 +1,7 @@
1
1
  import type { MenuViewContext, ViewState } from "./menu-types.js"
2
2
 
3
+ import { Effect, pipe } from "effect"
4
+
3
5
  // CHANGE: share menu escape handling across flows
4
6
  // WHY: avoid duplicated logic in TUI handlers
5
7
  // QUOTE(ТЗ): "А ты можешь сделать удобный выбор проектов?"
@@ -13,10 +15,31 @@ import type { MenuViewContext, ViewState } from "./menu-types.js"
13
15
 
14
16
  type MenuResetContext = Pick<MenuViewContext, "setView" | "setMessage">
15
17
 
16
- type StdoutWrite = typeof process.stdout.write
18
+ type OutputWrite = typeof process.stdout.write
17
19
 
18
20
  let stdoutPatched = false
19
21
  let stdoutMuted = false
22
+ let baseStdoutWrite: OutputWrite | null = null
23
+ let baseStderrWrite: OutputWrite | null = null
24
+
25
+ const wrapWrite = (baseWrite: OutputWrite): OutputWrite =>
26
+ (
27
+ chunk: string | Uint8Array,
28
+ encoding?: BufferEncoding | ((err?: Error | null) => void),
29
+ cb?: (err?: Error | null) => void
30
+ ) => {
31
+ if (stdoutMuted) {
32
+ const callback = typeof encoding === "function" ? encoding : cb
33
+ if (typeof callback === "function") {
34
+ callback()
35
+ }
36
+ return true
37
+ }
38
+ if (typeof encoding === "function") {
39
+ return baseWrite(chunk, encoding)
40
+ }
41
+ return baseWrite(chunk, encoding, cb)
42
+ }
20
43
 
21
44
  const disableMouseModes = (): void => {
22
45
  // Disable xterm/urxvt mouse tracking and "alternate scroll" mode (wheel -> arrow keys).
@@ -39,28 +62,116 @@ const ensureStdoutPatched = (): void => {
39
62
  if (stdoutPatched) {
40
63
  return
41
64
  }
42
- const baseWrite: StdoutWrite = process.stdout.write.bind(process.stdout)
43
- const mutedWrite: StdoutWrite = (
44
- chunk: string | Uint8Array,
45
- encoding?: BufferEncoding | ((err?: Error | null) => void),
46
- cb?: (err?: Error | null) => void
47
- ) => {
48
- if (stdoutMuted) {
49
- const callback = typeof encoding === "function" ? encoding : cb
50
- if (typeof callback === "function") {
51
- callback()
52
- }
53
- return true
65
+ baseStdoutWrite = process.stdout.write.bind(process.stdout)
66
+ baseStderrWrite = process.stderr.write.bind(process.stderr)
67
+
68
+ process.stdout.write = wrapWrite(baseStdoutWrite)
69
+ process.stderr.write = wrapWrite(baseStderrWrite)
70
+ stdoutPatched = true
71
+ }
72
+
73
+ // CHANGE: allow writing to the terminal even while stdout is muted
74
+ // WHY: we mute Ink renders during interactive commands, but still need to show prompts/errors
75
+ // REF: user-request-2026-02-18-tui-output-hidden
76
+ // SOURCE: n/a
77
+ // PURITY: SHELL
78
+ // EFFECT: n/a
79
+ // INVARIANT: bypasses the mute wrapper safely
80
+ export const writeToTerminal = (text: string): void => {
81
+ ensureStdoutPatched()
82
+ const write = baseStdoutWrite ?? process.stdout.write.bind(process.stdout)
83
+ write(text)
84
+ }
85
+
86
+ // CHANGE: keep the user on the primary screen until they acknowledge
87
+ // WHY: otherwise output from failed docker/gh commands gets hidden again when TUI resumes
88
+ // REF: user-request-2026-02-18-tui-output-hidden
89
+ // SOURCE: n/a
90
+ // PURITY: SHELL
91
+ // EFFECT: Effect<void, never, never>
92
+ // INVARIANT: no-op when stdin/stdout aren't TTY (CI/e2e)
93
+ export const pauseForEnter = (
94
+ prompt = "Press Enter to return to docker-git..."
95
+ ): Effect.Effect<void> => {
96
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
97
+ return Effect.void
98
+ }
99
+
100
+ return Effect.async((resume) => {
101
+ // Ensure the prompt isn't glued to the last command line.
102
+ writeToTerminal(`\n${prompt}\n`)
103
+ process.stdin.resume()
104
+
105
+ const cleanup = () => {
106
+ process.stdin.off("data", onData)
54
107
  }
55
- if (typeof encoding === "function") {
56
- return baseWrite(chunk, encoding)
108
+
109
+ const onData = () => {
110
+ cleanup()
111
+ resume(Effect.void)
57
112
  }
58
- return baseWrite(chunk, encoding, cb)
113
+
114
+ process.stdin.on("data", onData)
115
+
116
+ return Effect.sync(() => {
117
+ cleanup()
118
+ })
119
+ }).pipe(Effect.asVoid)
120
+ }
121
+
122
+ export const writeErrorAndPause = (renderedError: string): Effect.Effect<void> =>
123
+ pipe(
124
+ Effect.sync(() => {
125
+ writeToTerminal(`\n[docker-git] ${renderedError}\n`)
126
+ }),
127
+ Effect.zipRight(pauseForEnter()),
128
+ Effect.asVoid
129
+ )
130
+
131
+ export const withSuspendedTui = <A, E, R>(
132
+ effect: Effect.Effect<A, E, R>,
133
+ options?: {
134
+ readonly onError?: (error: E) => Effect.Effect<void>
135
+ readonly onResume?: () => void
59
136
  }
60
- process.stdout.write = mutedWrite
61
- stdoutPatched = true
137
+ ): Effect.Effect<A, E, R> => {
138
+ const withError = options?.onError
139
+ ? pipe(effect, Effect.tapError((error) => Effect.ignore(options.onError?.(error) ?? Effect.void)))
140
+ : effect
141
+
142
+ return pipe(
143
+ Effect.sync(suspendTui),
144
+ Effect.zipRight(withError),
145
+ Effect.ensuring(
146
+ Effect.sync(() => {
147
+ resumeTui()
148
+ options?.onResume?.()
149
+ })
150
+ )
151
+ )
152
+ }
153
+
154
+ export type SkipInputsContext = {
155
+ readonly setSkipInputs: (update: (value: number) => number) => void
156
+ }
157
+
158
+ export type SshActiveContext = {
159
+ readonly setSshActive: (active: boolean) => void
62
160
  }
63
161
 
162
+ export const resumeWithSkipInputs = (context: SkipInputsContext, extra?: () => void) => () => {
163
+ extra?.()
164
+ context.setSkipInputs(() => 2)
165
+ }
166
+
167
+ export const resumeSshWithSkipInputs = (context: SkipInputsContext & SshActiveContext) =>
168
+ resumeWithSkipInputs(context, () => {
169
+ context.setSshActive(false)
170
+ })
171
+
172
+ export const pauseOnError = <E>(render: (error: E) => string) => (error: E): Effect.Effect<void> =>
173
+ writeErrorAndPause(render(error))
174
+
64
175
  // CHANGE: toggle stdout write muting for Ink rendering
65
176
  // WHY: allow SSH sessions to own the terminal without TUI redraws
66
177
  // QUOTE(ТЗ): "при изменении разершения он всё ломает?"
@@ -94,7 +205,9 @@ export const suspendTui = (): void => {
94
205
  if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
95
206
  process.stdin.setRawMode(false)
96
207
  }
97
- process.stdout.write("\u001B[?1049l\u001B[2J\u001B[H")
208
+ // Switch back to the primary screen so interactive commands (ssh/gh/codex)
209
+ // can render normally. Do not clear it: users may need scrollback (OAuth codes/URLs).
210
+ process.stdout.write("\u001B[?1049l")
98
211
  setStdoutMuted(true)
99
212
  }
100
213
 
@@ -114,6 +227,7 @@ export const resumeTui = (): void => {
114
227
  }
115
228
  setStdoutMuted(false)
116
229
  disableMouseModes()
230
+ // Return to the alternate screen for Ink rendering.
117
231
  process.stdout.write("\u001B[?1049h\u001B[2J\u001B[H")
118
232
  if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
119
233
  process.stdin.setRawMode(true)
@@ -128,7 +242,8 @@ export const leaveTui = (): void => {
128
242
  // Ensure we don't leave the terminal in a broken "mouse reporting" mode.
129
243
  setStdoutMuted(false)
130
244
  disableMouseModes()
131
- process.stdout.write("\u001B[?1049l\u001B[2J\u001B[H")
245
+ // Restore the primary screen on exit without clearing it (keeps useful scrollback).
246
+ process.stdout.write("\u001B[?1049l")
132
247
  if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
133
248
  process.stdin.setRawMode(false)
134
249
  }