redmine-context 1.0.0

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 (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/dist/bundle/index.d.ts +5 -0
  4. package/dist/bundle/index.js +5 -0
  5. package/dist/bundle/json.d.ts +90 -0
  6. package/dist/bundle/json.js +266 -0
  7. package/dist/bundle/markdown.d.ts +75 -0
  8. package/dist/bundle/markdown.js +294 -0
  9. package/dist/bundle/search-list.d.ts +43 -0
  10. package/dist/bundle/search-list.js +53 -0
  11. package/dist/bundle/stable-stringify.d.ts +26 -0
  12. package/dist/bundle/stable-stringify.js +50 -0
  13. package/dist/cache/contract.d.ts +157 -0
  14. package/dist/cache/contract.js +0 -0
  15. package/dist/cache/disk-index.d.ts +82 -0
  16. package/dist/cache/disk-index.js +220 -0
  17. package/dist/cache/disk.d.ts +133 -0
  18. package/dist/cache/disk.js +313 -0
  19. package/dist/cache/gc.d.ts +78 -0
  20. package/dist/cache/gc.js +123 -0
  21. package/dist/cache/get-or-compute.d.ts +36 -0
  22. package/dist/cache/get-or-compute.js +52 -0
  23. package/dist/cache/index.d.ts +9 -0
  24. package/dist/cache/index.js +8 -0
  25. package/dist/cache/keys.d.ts +76 -0
  26. package/dist/cache/keys.js +78 -0
  27. package/dist/cache/memory.d.ts +48 -0
  28. package/dist/cache/memory.js +110 -0
  29. package/dist/cache-first.d.ts +127 -0
  30. package/dist/cache-first.js +227 -0
  31. package/dist/client/errors.d.ts +33 -0
  32. package/dist/client/errors.js +49 -0
  33. package/dist/client/http.d.ts +110 -0
  34. package/dist/client/http.js +207 -0
  35. package/dist/client/index.d.ts +5 -0
  36. package/dist/client/index.js +5 -0
  37. package/dist/client/issues.d.ts +71 -0
  38. package/dist/client/issues.js +100 -0
  39. package/dist/client/search.d.ts +58 -0
  40. package/dist/client/search.js +81 -0
  41. package/dist/config/credentials.d.ts +247 -0
  42. package/dist/config/credentials.js +427 -0
  43. package/dist/config/doctor.d.ts +123 -0
  44. package/dist/config/doctor.js +260 -0
  45. package/dist/config/index.d.ts +6 -0
  46. package/dist/config/index.js +6 -0
  47. package/dist/config/keyring.d.ts +96 -0
  48. package/dist/config/keyring.js +158 -0
  49. package/dist/config/login.d.ts +97 -0
  50. package/dist/config/login.js +189 -0
  51. package/dist/config/settings.d.ts +94 -0
  52. package/dist/config/settings.js +140 -0
  53. package/dist/contract.d.ts +173 -0
  54. package/dist/contract.js +27 -0
  55. package/dist/core.d.ts +1 -0
  56. package/dist/core.js +8 -0
  57. package/dist/extract/audio-extractor.d.ts +105 -0
  58. package/dist/extract/audio-extractor.js +156 -0
  59. package/dist/extract/audio.d.ts +126 -0
  60. package/dist/extract/audio.js +184 -0
  61. package/dist/extract/dispatcher.d.ts +132 -0
  62. package/dist/extract/dispatcher.js +115 -0
  63. package/dist/extract/download.d.ts +111 -0
  64. package/dist/extract/download.js +261 -0
  65. package/dist/extract/duration.d.ts +106 -0
  66. package/dist/extract/duration.js +148 -0
  67. package/dist/extract/ffmpeg.d.ts +56 -0
  68. package/dist/extract/ffmpeg.js +95 -0
  69. package/dist/extract/gguf.d.ts +137 -0
  70. package/dist/extract/gguf.js +215 -0
  71. package/dist/extract/index.d.ts +19 -0
  72. package/dist/extract/index.js +19 -0
  73. package/dist/extract/magic.d.ts +80 -0
  74. package/dist/extract/magic.js +282 -0
  75. package/dist/extract/ooxml.d.ts +131 -0
  76. package/dist/extract/ooxml.js +336 -0
  77. package/dist/extract/pdf.d.ts +147 -0
  78. package/dist/extract/pdf.js +322 -0
  79. package/dist/extract/queue.d.ts +167 -0
  80. package/dist/extract/queue.js +217 -0
  81. package/dist/extract/subprocess.d.ts +145 -0
  82. package/dist/extract/subprocess.js +181 -0
  83. package/dist/extract/tesseract.d.ts +153 -0
  84. package/dist/extract/tesseract.js +321 -0
  85. package/dist/extract/video-extractor.d.ts +84 -0
  86. package/dist/extract/video-extractor.js +89 -0
  87. package/dist/extract/video.d.ts +198 -0
  88. package/dist/extract/video.js +418 -0
  89. package/dist/extract/which.d.ts +63 -0
  90. package/dist/extract/which.js +72 -0
  91. package/dist/extract/whisper-extract.d.ts +211 -0
  92. package/dist/extract/whisper-extract.js +323 -0
  93. package/dist/extract/whisper.d.ts +50 -0
  94. package/dist/extract/whisper.js +67 -0
  95. package/dist/extract/zip.d.ts +50 -0
  96. package/dist/extract/zip.js +165 -0
  97. package/dist/extract-issue-attachments.d.ts +82 -0
  98. package/dist/extract-issue-attachments.js +156 -0
  99. package/dist/fetch-attachment-text.d.ts +113 -0
  100. package/dist/fetch-attachment-text.js +155 -0
  101. package/dist/fetch-issue-bundle.d.ts +81 -0
  102. package/dist/fetch-issue-bundle.js +93 -0
  103. package/dist/fetch-issue-search.d.ts +74 -0
  104. package/dist/fetch-issue-search.js +120 -0
  105. package/dist/index.d.ts +21 -0
  106. package/dist/index.js +67 -0
  107. package/dist/normalize/collections.d.ts +52 -0
  108. package/dist/normalize/collections.js +170 -0
  109. package/dist/normalize/helpers.d.ts +35 -0
  110. package/dist/normalize/helpers.js +59 -0
  111. package/dist/normalize/index.d.ts +2 -0
  112. package/dist/normalize/index.js +2 -0
  113. package/dist/normalize/issue.d.ts +34 -0
  114. package/dist/normalize/issue.js +154 -0
  115. package/dist/surfaces/cli/commands.d.ts +61 -0
  116. package/dist/surfaces/cli/commands.js +262 -0
  117. package/dist/surfaces/cli/main.d.ts +32 -0
  118. package/dist/surfaces/cli/main.js +210 -0
  119. package/dist/surfaces/cli/prompts.d.ts +66 -0
  120. package/dist/surfaces/cli/prompts.js +147 -0
  121. package/dist/surfaces/cli/tty.d.ts +27 -0
  122. package/dist/surfaces/cli/tty.js +35 -0
  123. package/dist/surfaces/cli/types.d.ts +39 -0
  124. package/dist/surfaces/cli/types.js +7 -0
  125. package/dist/surfaces/mcp/server.d.ts +171 -0
  126. package/dist/surfaces/mcp/server.js +427 -0
  127. package/dist/surfaces/tui/app.d.ts +55 -0
  128. package/dist/surfaces/tui/app.js +180 -0
  129. package/dist/surfaces/tui/attachment-status.d.ts +79 -0
  130. package/dist/surfaces/tui/attachment-status.js +113 -0
  131. package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
  132. package/dist/surfaces/tui/components/breadcrumb.js +31 -0
  133. package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
  134. package/dist/surfaces/tui/components/gradient-text.js +75 -0
  135. package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
  136. package/dist/surfaces/tui/components/scroll-view.js +77 -0
  137. package/dist/surfaces/tui/components/spinner.d.ts +12 -0
  138. package/dist/surfaces/tui/components/spinner.js +39 -0
  139. package/dist/surfaces/tui/components/text-input.d.ts +45 -0
  140. package/dist/surfaces/tui/components/text-input.js +114 -0
  141. package/dist/surfaces/tui/format-file-size.d.ts +24 -0
  142. package/dist/surfaces/tui/format-file-size.js +43 -0
  143. package/dist/surfaces/tui/glyphs.d.ts +47 -0
  144. package/dist/surfaces/tui/glyphs.js +84 -0
  145. package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
  146. package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
  147. package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
  148. package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
  149. package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
  150. package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
  151. package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
  152. package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
  153. package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
  154. package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
  155. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
  156. package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
  157. package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
  158. package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
  159. package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
  160. package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
  161. package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
  162. package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
  163. package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
  164. package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
  165. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
  166. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
  167. package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
  168. package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
  169. package/dist/surfaces/tui/index.d.ts +50 -0
  170. package/dist/surfaces/tui/index.js +158 -0
  171. package/dist/surfaces/tui/instance.d.ts +37 -0
  172. package/dist/surfaces/tui/instance.js +36 -0
  173. package/dist/surfaces/tui/job-registry.d.ts +113 -0
  174. package/dist/surfaces/tui/job-registry.js +123 -0
  175. package/dist/surfaces/tui/job-status.d.ts +45 -0
  176. package/dist/surfaces/tui/job-status.js +81 -0
  177. package/dist/surfaces/tui/navigation.d.ts +74 -0
  178. package/dist/surfaces/tui/navigation.js +87 -0
  179. package/dist/surfaces/tui/palettes.d.ts +38 -0
  180. package/dist/surfaces/tui/palettes.js +244 -0
  181. package/dist/surfaces/tui/screen.d.ts +30 -0
  182. package/dist/surfaces/tui/screen.js +51 -0
  183. package/dist/surfaces/tui/screens/about.d.ts +2 -0
  184. package/dist/surfaces/tui/screens/about.js +35 -0
  185. package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
  186. package/dist/surfaces/tui/screens/appearance.js +74 -0
  187. package/dist/surfaces/tui/screens/config.d.ts +2 -0
  188. package/dist/surfaces/tui/screens/config.js +82 -0
  189. package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
  190. package/dist/surfaces/tui/screens/doctor.js +109 -0
  191. package/dist/surfaces/tui/screens/export.d.ts +2 -0
  192. package/dist/surfaces/tui/screens/export.js +168 -0
  193. package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
  194. package/dist/surfaces/tui/screens/home-selection.js +80 -0
  195. package/dist/surfaces/tui/screens/home.d.ts +2 -0
  196. package/dist/surfaces/tui/screens/home.js +200 -0
  197. package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
  198. package/dist/surfaces/tui/screens/issue-detail.js +182 -0
  199. package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
  200. package/dist/surfaces/tui/screens/jobs.js +89 -0
  201. package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
  202. package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
  203. package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
  204. package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
  205. package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
  206. package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
  207. package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
  208. package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
  209. package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
  210. package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
  211. package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
  212. package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
  213. package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
  214. package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
  215. package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
  216. package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
  217. package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
  218. package/dist/surfaces/tui/screens/welcome.js +95 -0
  219. package/dist/surfaces/tui/status-color.d.ts +21 -0
  220. package/dist/surfaces/tui/status-color.js +23 -0
  221. package/dist/surfaces/tui/symbols.d.ts +231 -0
  222. package/dist/surfaces/tui/symbols.js +14 -0
  223. package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
  224. package/dist/surfaces/tui/terminal-colors.js +39 -0
  225. package/dist/surfaces/tui/theme.d.ts +156 -0
  226. package/dist/surfaces/tui/theme.js +86 -0
  227. package/dist/surfaces/tui/truncate.d.ts +31 -0
  228. package/dist/surfaces/tui/truncate.js +81 -0
  229. package/package.json +93 -0
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Superfície MCP (stdio) do `redmine-context` (M1-12).
3
+ *
4
+ * Expõe a tool read-only `get_issue_context(issue_id, format?)`, que reutiliza a
5
+ * orquestração `fetchIssueBundle` do core. Segue a mesma fronteira da CLI
6
+ * (ADR-005): importa o core EXCLUSIVAMENTE por `../../index.js`; nenhum módulo
7
+ * interno é acessado diretamente (regra eslint `no-restricted-imports`).
8
+ *
9
+ * Segurança: NENHUMA tool aceita URL/host arbitrário — a instância vem sempre da
10
+ * config/env do processo (`REDMINE_URL`; sem ela, a URL persistida no `login`,
11
+ * #187) + cascata de credencial. Quando a URL vem da persistida (fonte mutável),
12
+ * a credencial de AMBIENTE (`REDMINE_API_KEY`, instance-agnóstica) é DESABILITADA
13
+ * — só credencial pinada à instância (keychain/arquivo) é aceita, impedindo que um
14
+ * `settings.json` adulterado exfiltre a chave (ver `resolveInstance`). No transporte
15
+ * stdio o protocolo ocupa o stdout, portanto todo diagnóstico vai para stderr.
16
+ */
17
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
+ import { z } from 'zod';
20
+ import * as core from '../../index.js';
21
+ import { fenceBlock } from '../../index.js';
22
+ /** Interpreta `REDMINE_INSECURE` (`1`/`true`, case-insensitive) como boolean. */
23
+ function parseInsecure(env) {
24
+ const raw = env.REDMINE_INSECURE;
25
+ return raw !== undefined && /^(1|true)$/i.test(raw.trim());
26
+ }
27
+ /** Nome canônico da tool de contexto de issue. */
28
+ export const TOOL_NAME = 'get_issue_context';
29
+ /** Nome canônico da tool de busca de issues. */
30
+ export const SEARCH_TOOL_NAME = 'search_issues';
31
+ /** Nome canônico da tool de texto de anexo. */
32
+ export const ATTACHMENT_TOOL_NAME = 'get_attachment_text';
33
+ /** Limite default de resultados da tool `search_issues` (documentado no schema). */
34
+ export const SEARCH_DEFAULT_LIMIT = 25;
35
+ /** Teto de resultados aceito pela tool `search_issues`. */
36
+ const SEARCH_MAX_LIMIT = 100;
37
+ /** Schema zod dos argumentos da tool (sem URL/host: a instância vem da env). */
38
+ const INPUT_SCHEMA = {
39
+ issue_id: z.number().int().positive().describe('Identificador numérico da issue no Redmine'),
40
+ format: z
41
+ .enum(['markdown', 'json'])
42
+ .optional()
43
+ .describe("Formato de saída: 'markdown' (padrão) ou 'json'"),
44
+ extract_attachments: z
45
+ .boolean()
46
+ .optional()
47
+ .describe('Extrai o texto (OCR) dos anexos de imagem e o embute no bundle. Default: false (adiciona latência de download+OCR). O M4 traz o modo cache-first/processing.'),
48
+ };
49
+ /** Schema zod da tool `get_attachment_text` (read-only, sem URL/host). */
50
+ const ATTACHMENT_INPUT_SCHEMA = {
51
+ issue_id: z.number().int().positive().describe('Identificador numérico da issue que contém o anexo'),
52
+ attachment_id: z.number().int().positive().describe('Identificador numérico do anexo a extrair'),
53
+ };
54
+ /** Schema zod da tool `search_issues` (read-only, sem URL/host). */
55
+ const SEARCH_INPUT_SCHEMA = {
56
+ query: z
57
+ .string()
58
+ .min(1)
59
+ .optional()
60
+ .describe('Termo full-text via /search.json (best-effort). Se a busca falhar, degrada para os filtros estruturados com aviso.'),
61
+ project_id: z.number().int().positive().optional().describe('Filtro estruturado project_id'),
62
+ status_id: z
63
+ .union([z.number().int(), z.string()])
64
+ .optional()
65
+ .describe("Filtro status_id: um id, 'open', 'closed' ou '*'"),
66
+ assigned_to_id: z
67
+ .union([z.number().int(), z.string()])
68
+ .optional()
69
+ .describe("Filtro assigned_to_id: um id ou 'me'"),
70
+ updated_on: z
71
+ .string()
72
+ .optional()
73
+ .describe('Filtro updated_on no formato do Redmine (ex.: >=2026-01-01, <=2026-12-31)'),
74
+ limit: z
75
+ .number()
76
+ .int()
77
+ .positive()
78
+ .max(SEARCH_MAX_LIMIT)
79
+ .optional()
80
+ .describe(`Máximo de resultados paginados (default ${SEARCH_DEFAULT_LIMIT}, teto ${SEARCH_MAX_LIMIT})`),
81
+ };
82
+ /** Extrai uma mensagem legível de um erro desconhecido. */
83
+ function messageOf(error) {
84
+ return error instanceof Error ? error.message : String(error);
85
+ }
86
+ /** Monta um `CallToolResult` de erro (isError) com uma única mensagem de texto. */
87
+ function errorResult(message) {
88
+ return { content: [{ type: 'text', text: message }], isError: true };
89
+ }
90
+ /** Monta um `CallToolResult` de sucesso com o bundle serializado. */
91
+ function textResult(content) {
92
+ return { content: [{ type: 'text', text: content }] };
93
+ }
94
+ /**
95
+ * Traduz um erro da operação em uma mensagem MCP clara e tipada.
96
+ *
97
+ * 403/404/401 recebem texto orientado; os demais propagam a mensagem original.
98
+ * Nenhum resultado é cacheado aqui — o erro é sempre recomputado por chamada.
99
+ *
100
+ * @param error - Erro capturado durante o fetch/bundle.
101
+ * @param issueId - Id da issue para compor a mensagem.
102
+ * @returns A mensagem a ser exibida no `isError`.
103
+ */
104
+ function typedErrorMessage(error, issueId) {
105
+ if (error instanceof core.RedmineNotFoundError) {
106
+ return `Issue #${issueId} não encontrada (404). Verifique o id na instância configurada.`;
107
+ }
108
+ if (error instanceof core.RedmineForbiddenError) {
109
+ return `Acesso negado à issue #${issueId} (403). A credencial não tem permissão para vê-la.`;
110
+ }
111
+ if (error instanceof core.RedmineAuthError) {
112
+ return 'Falha de autenticação (401). Verifique a credencial em REDMINE_API_KEY.';
113
+ }
114
+ return messageOf(error);
115
+ }
116
+ /**
117
+ * Resolve `REDMINE_URL` + credencial da env (nunca de argumentos das tools).
118
+ *
119
+ * Fonte única da política "sem URL/host nos argumentos": ambas as tools passam
120
+ * por aqui. Em falha devolve um {@link CallToolResult} de erro orientado; em
121
+ * sucesso, a instância pronta para uso.
122
+ *
123
+ * @param deps - Ver {@link McpServerDeps}.
124
+ * @returns A instância resolvida, ou um CallToolResult de erro (`isError`).
125
+ */
126
+ async function resolveInstance(deps) {
127
+ // REDMINE_URL tem precedência; sem ela, cai na URL persistida no login (#187),
128
+ // via o resolvedor único (trata string vazia como ausente). Continua UMA
129
+ // instância configurada (env/persistida) — nunca de argumento de tool.
130
+ const persistedUrl = deps.settings ? await deps.settings.getInstanceUrl() : undefined;
131
+ const resolved = core.resolveInstanceUrl({ envUrl: deps.env.REDMINE_URL, persistedUrl });
132
+ if (resolved === undefined) {
133
+ return errorResult('Instância não configurada. Defina REDMINE_URL no ambiente do processo ou rode `redmine-context login`.');
134
+ }
135
+ const { url: baseUrl, origin } = resolved;
136
+ // Auditabilidade (#187): quando a instância NÃO veio da env (fallback persistido),
137
+ // registra a URL em uso no diagnóstico (o stdout é do protocolo → vai ao stderr).
138
+ if (origin !== 'env') {
139
+ deps.log?.(`instância resolvida via ${origin}: ${baseUrl}`);
140
+ }
141
+ let apiKey;
142
+ try {
143
+ apiKey = await deps.resolveApiKey(baseUrl, {
144
+ env: deps.env,
145
+ // SEGURANÇA (#187): URL de fonte MUTÁVEL (settings.json persistido, origem
146
+ // `config`) NÃO pode usar a REDMINE_API_KEY instance-agnóstica — só credencial
147
+ // PINADA à instância (keychain/arquivo). Impede que um settings.json adulterado
148
+ // redirecione a chave real para um host arbitrário (fail-closed).
149
+ allowEnvFallback: origin !== 'config',
150
+ // Aviso de migração/keychain vai para o diagnóstico (stdout é do protocolo).
151
+ logger: { warn: (message) => deps.log?.(message) },
152
+ });
153
+ }
154
+ catch (error) {
155
+ return errorResult(messageOf(error));
156
+ }
157
+ if (apiKey === undefined || apiKey.length === 0) {
158
+ return errorResult(`Nenhuma credencial encontrada para ${baseUrl}. Configure REDMINE_API_KEY (ou o arquivo de credenciais via 'redmine-context login').`);
159
+ }
160
+ return { baseUrl, apiKey };
161
+ }
162
+ /** Type guard: distingue a instância resolvida de um CallToolResult de erro. */
163
+ function isResolved(value) {
164
+ return 'baseUrl' in value;
165
+ }
166
+ /**
167
+ * Cria o handler da tool `get_issue_context`, testável isoladamente.
168
+ *
169
+ * Resolve a instância/credencial a partir da env (nunca de argumentos), invoca
170
+ * `fetchIssueBundle` e devolve o bundle. Erros viram `isError` com mensagem
171
+ * tipada; a ausência de `REDMINE_URL`/credencial orienta a configuração.
172
+ *
173
+ * @param deps - Ver {@link McpServerDeps}.
174
+ * @returns Função assíncrona que recebe os argumentos e devolve um CallToolResult.
175
+ * @example
176
+ * const handler = createGetIssueContextHandler(defaultMcpDeps());
177
+ * const result = await handler({ issue_id: 42, format: 'json' });
178
+ */
179
+ export function createGetIssueContextHandler(deps) {
180
+ return async (args) => {
181
+ const resolved = await resolveInstance(deps);
182
+ if (!isResolved(resolved))
183
+ return resolved;
184
+ const { baseUrl, apiKey } = resolved;
185
+ const format = args.format === 'json' ? 'json' : 'md';
186
+ try {
187
+ let content;
188
+ for await (const event of deps.fetchIssueBundle({
189
+ baseUrl,
190
+ apiKey,
191
+ issueId: args.issue_id,
192
+ format,
193
+ toolVersion: deps.toolVersion,
194
+ insecure: deps.insecure ?? false,
195
+ extractAttachments: args.extract_attachments ?? false,
196
+ // Cache-first (#70): embute o texto já pronto e marca o restante como
197
+ // `processing` sem bloquear na extração cara (a computação corre em background).
198
+ cacheFirst: true,
199
+ })) {
200
+ if (event.kind === 'progress') {
201
+ deps.log?.(event.message);
202
+ }
203
+ else {
204
+ content = event.value.content;
205
+ }
206
+ }
207
+ if (content === undefined) {
208
+ return errorResult('A operação não produziu um bundle.');
209
+ }
210
+ return textResult(content);
211
+ }
212
+ catch (error) {
213
+ return errorResult(typedErrorMessage(error, args.issue_id));
214
+ }
215
+ };
216
+ }
217
+ /**
218
+ * Traduz erros da busca em mensagens claras (sem `issue_id`, que não se aplica).
219
+ *
220
+ * @param error - Erro capturado durante a busca.
221
+ * @returns Mensagem a exibir no `isError`.
222
+ */
223
+ function typedSearchErrorMessage(error) {
224
+ if (error instanceof core.RedmineAuthError) {
225
+ return 'Falha de autenticação (401). Verifique a credencial em REDMINE_API_KEY.';
226
+ }
227
+ if (error instanceof core.RedmineForbiddenError) {
228
+ return 'Acesso negado (403). A credencial não tem permissão para esta busca.';
229
+ }
230
+ return messageOf(error);
231
+ }
232
+ /** Monta os filtros estruturados a partir dos argumentos definidos da tool. */
233
+ function searchFiltersOf(args) {
234
+ return {
235
+ project_id: args.project_id,
236
+ status_id: args.status_id,
237
+ assigned_to_id: args.assigned_to_id,
238
+ updated_on: args.updated_on,
239
+ };
240
+ }
241
+ /**
242
+ * Cria o handler da tool `search_issues`, testável isoladamente.
243
+ *
244
+ * Resolve a instância/credencial da env (nunca de argumentos), delega à
245
+ * orquestração `fetchIssueSearch` (filtros + full-text best-effort) e devolve a
246
+ * lista compacta em Markdown. A degradação da busca full-text NÃO é erro: o
247
+ * aviso já vem embutido no payload retornado pelo core.
248
+ *
249
+ * @param deps - Ver {@link McpServerDeps}.
250
+ * @returns Função assíncrona que recebe os argumentos e devolve um CallToolResult.
251
+ * @example
252
+ * const handler = createSearchIssuesHandler(defaultMcpDeps());
253
+ * const result = await handler({ query: 'timeout', project_id: 5 });
254
+ */
255
+ export function createSearchIssuesHandler(deps) {
256
+ return async (args) => {
257
+ const resolved = await resolveInstance(deps);
258
+ if (!isResolved(resolved))
259
+ return resolved;
260
+ const { baseUrl, apiKey } = resolved;
261
+ try {
262
+ const result = await deps.searchIssues({
263
+ baseUrl,
264
+ apiKey,
265
+ filters: searchFiltersOf(args),
266
+ query: args.query,
267
+ limit: args.limit ?? SEARCH_DEFAULT_LIMIT,
268
+ });
269
+ // Degradação vira aviso no corpo (não isError); logamos para diagnóstico.
270
+ if (result.degraded)
271
+ deps.log?.(result.warnings.join(' '));
272
+ return textResult(result.content);
273
+ }
274
+ catch (error) {
275
+ return errorResult(typedSearchErrorMessage(error));
276
+ }
277
+ };
278
+ }
279
+ /**
280
+ * Traduz erros da extração de anexo em mensagens claras e tipadas.
281
+ *
282
+ * 404/403/401 do Redmine e o {@link core.AttachmentNotFoundError} recebem texto
283
+ * orientado; os demais propagam a mensagem original. Nenhum resultado é cacheado
284
+ * aqui — o erro é sempre recomputado por chamada (sem cache indevido).
285
+ *
286
+ * @param error - Erro capturado durante o fetch/extração.
287
+ * @param issueId - Id da issue para compor a mensagem.
288
+ * @param attachmentId - Id do anexo para compor a mensagem.
289
+ * @returns A mensagem a ser exibida no `isError`.
290
+ */
291
+ function typedAttachmentErrorMessage(error, issueId, attachmentId) {
292
+ if (error instanceof core.AttachmentNotFoundError) {
293
+ return `Anexo #${attachmentId} não encontrado na issue #${issueId}. Verifique o id do anexo.`;
294
+ }
295
+ return typedErrorMessage(error, issueId);
296
+ }
297
+ /**
298
+ * Renderiza o {@link AttachmentTextResult} como conteúdo MCP de sucesso.
299
+ *
300
+ * Com texto (`done`/`text`): devolve-o dentro da fence `<untrusted-content>` — é
301
+ * conteúdo DERIVADO do anexo, logo não confiável (padrão do repo). Sem texto
302
+ * (`pending`/`processing`/`skipped`/`unsupported`/`failed`): devolve `status` +
303
+ * `reason`/`hint` legíveis — NUNCA um erro genérico; o `hint` é texto nosso
304
+ * (ex.: como instalar o tesseract), fora da fence por ser confiável.
305
+ *
306
+ * @param result - Resultado da extração do anexo.
307
+ * @returns CallToolResult de sucesso (não `isError`).
308
+ */
309
+ function renderAttachmentText(result) {
310
+ const { extraction } = result;
311
+ if (typeof extraction.text === 'string' && extraction.text.trim() !== '') {
312
+ return textResult(fenceBlock(extraction.text));
313
+ }
314
+ const lines = [`Anexo #${result.attachmentId}: extração ${extraction.status} (sem texto disponível).`];
315
+ const reason = extraction.metadata?.['reason'];
316
+ const hint = extraction.metadata?.['hint'];
317
+ if (typeof reason === 'string')
318
+ lines.push(`Motivo: ${reason}`);
319
+ if (typeof hint === 'string')
320
+ lines.push(hint);
321
+ return textResult(lines.join('\n'));
322
+ }
323
+ /**
324
+ * Cria o handler da tool `get_attachment_text`, testável isoladamente.
325
+ *
326
+ * Resolve a instância/credencial da env (nunca de argumentos), delega à
327
+ * orquestração cache-first `fetchAttachmentText` (texto já cacheado volta na
328
+ * hora; mídia pesada ainda não processada volta como `processing` SEM bloquear —
329
+ * M4-11 #70) e devolve o texto extraído dentro da fence untrusted. Anexo não
330
+ * processável / `processing` vira status legível (não `isError`); 403/404 do
331
+ * Redmine e anexo inexistente viram `isError` tipado.
332
+ *
333
+ * @param deps - Ver {@link McpServerDeps}.
334
+ * @returns Função assíncrona que recebe os argumentos e devolve um CallToolResult.
335
+ * @example
336
+ * const handler = createGetAttachmentTextHandler(defaultMcpDeps());
337
+ * const result = await handler({ issue_id: 42, attachment_id: 77 });
338
+ */
339
+ export function createGetAttachmentTextHandler(deps) {
340
+ return async (args) => {
341
+ const resolved = await resolveInstance(deps);
342
+ if (!isResolved(resolved))
343
+ return resolved;
344
+ const { baseUrl, apiKey } = resolved;
345
+ try {
346
+ const result = await deps.fetchAttachmentText({
347
+ baseUrl,
348
+ apiKey,
349
+ issueId: args.issue_id,
350
+ attachmentId: args.attachment_id,
351
+ insecure: deps.insecure ?? false,
352
+ });
353
+ return renderAttachmentText(result);
354
+ }
355
+ catch (error) {
356
+ return errorResult(typedAttachmentErrorMessage(error, args.issue_id, args.attachment_id));
357
+ }
358
+ };
359
+ }
360
+ /**
361
+ * Constrói um {@link McpServer} com a tool `get_issue_context` registrada.
362
+ *
363
+ * A tool é read-only e não expõe URL/host — a instância vem sempre da env.
364
+ *
365
+ * @param deps - Ver {@link McpServerDeps}.
366
+ * @returns O server pronto para `connect(transport)`.
367
+ */
368
+ export function createMcpServer(deps) {
369
+ const server = new McpServer({ name: core.TOOL_NAME, version: deps.toolVersion });
370
+ const handler = createGetIssueContextHandler(deps);
371
+ const searchHandler = createSearchIssuesHandler(deps);
372
+ const attachmentHandler = createGetAttachmentTextHandler(deps);
373
+ server.registerTool(TOOL_NAME, {
374
+ title: 'Contexto de issue do Redmine',
375
+ description: 'Busca uma issue na instância Redmine configurada (REDMINE_URL) e retorna seu contexto completo empacotado. Read-only.',
376
+ inputSchema: INPUT_SCHEMA,
377
+ annotations: { readOnlyHint: true, openWorldHint: true },
378
+ }, (args) => handler(args));
379
+ server.registerTool(SEARCH_TOOL_NAME, {
380
+ title: 'Buscar issues no Redmine',
381
+ description: 'Busca issues na instância configurada (REDMINE_URL) por filtros estruturados (project_id, status_id, assigned_to_id, updated_on) e, opcionalmente, texto livre (query, best-effort via /search). Retorna uma lista compacta paginada. Read-only.',
382
+ inputSchema: SEARCH_INPUT_SCHEMA,
383
+ annotations: { readOnlyHint: true, openWorldHint: true },
384
+ }, (args) => searchHandler(args));
385
+ server.registerTool(ATTACHMENT_TOOL_NAME, {
386
+ title: 'Texto extraído de um anexo do Redmine',
387
+ description: 'Extrai (com cache) o texto de um anexo de uma issue na instância configurada (REDMINE_URL) e o retorna dentro de uma fence de conteúdo não confiável. Anexo não processável retorna o status/motivo legível (skipped/unsupported/failed), não um erro. Read-only.',
388
+ inputSchema: ATTACHMENT_INPUT_SCHEMA,
389
+ annotations: { readOnlyHint: true, openWorldHint: true },
390
+ }, (args) => attachmentHandler(args));
391
+ return server;
392
+ }
393
+ /** Constrói as dependências default apontando para o core e o processo reais. */
394
+ export function defaultMcpDeps() {
395
+ return {
396
+ fetchIssueBundle: core.fetchIssueBundle,
397
+ searchIssues: core.fetchIssueSearch,
398
+ fetchAttachmentText: core.fetchAttachmentTextCacheFirst,
399
+ resolveApiKey: core.resolveApiKey,
400
+ env: process.env,
401
+ settings: core.defaultSettingsStore(),
402
+ toolVersion: core.TOOL_VERSION,
403
+ insecure: parseInsecure(process.env),
404
+ // Diagnóstico SEMPRE em stderr: o stdout pertence ao protocolo stdio.
405
+ log: (message) => void process.stderr.write(`${message}\n`),
406
+ };
407
+ }
408
+ /* c8 ignore start -- wire de I/O (stdio transport); coberto pelo teste E2E real (#20). */
409
+ /**
410
+ * Sobe o server MCP no transporte stdio e resolve quando a conexão fecha.
411
+ *
412
+ * O stdout é reservado ao protocolo; qualquer log vai para stderr.
413
+ *
414
+ * @param overrides - Dependências injetáveis (defaults via {@link defaultMcpDeps}).
415
+ * @returns Promise que resolve quando o transporte encerra.
416
+ */
417
+ export async function runStdioServer(overrides = {}) {
418
+ const deps = { ...defaultMcpDeps(), ...overrides };
419
+ const server = createMcpServer(deps);
420
+ const transport = new StdioServerTransport();
421
+ await server.connect(transport);
422
+ deps.log?.('redmine-context MCP server pronto (stdio).');
423
+ await new Promise((resolve) => {
424
+ transport.onclose = () => resolve();
425
+ });
426
+ }
427
+ /* c8 ignore stop */
@@ -0,0 +1,55 @@
1
+ import type { SettingsStore } from '../../index.js';
2
+ import { type OnboardingReAuth } from './screens/onboarding/onboarding-context.js';
3
+ import { type ScreenName } from './screen.js';
4
+ import { type Theme, type ThemeController } from './theme.js';
5
+ /**
6
+ * O que `Esc` deve fazer dado o estado atual — extraída como função pura
7
+ * (fix do review #119) para poder ser testada isoladamente
8
+ * (`tests/surfaces/tui/app.test.tsx`) sem precisar montar toda a árvore do
9
+ * Ink nem depender de uma tela de dados real (#29+) para chegar no estado de
10
+ * re-auth ativo.
11
+ */
12
+ export type EscapeAction = {
13
+ kind: 'abort-reauth';
14
+ origin: ScreenName;
15
+ } | {
16
+ kind: 'pop';
17
+ };
18
+ /**
19
+ * Decide a ação de `Esc`: abandono do re-auth (fix do review #119) quando a
20
+ * tela atual é do fluxo de onboarding (`onboarding-*`) E há um `reAuth` em
21
+ * andamento; `pop()` simples em qualquer outro caso (comportamento original,
22
+ * M2-04).
23
+ */
24
+ export declare function resolveEscapeAction(current: ScreenName, reAuth: OnboardingReAuth | undefined): EscapeAction;
25
+ /**
26
+ * App raiz da TUI: só monta os providers (tema, pilha de navegação,
27
+ * onboarding — M2-05.1). `OnboardingProvider` recebe os callbacks REAIS de
28
+ * `useOnboardingCallbacks` (#28) — o wiring com o core (`loginWithPassword`,
29
+ * `validateApiKey`, credential cascade) vive só ali, nunca nas telas em si
30
+ * (ADR-005).
31
+ */
32
+ /** Props do {@link App} — todas opcionais para não quebrar `render(<App/>)` nos testes. */
33
+ export interface AppProps {
34
+ /**
35
+ * Id da paleta inicial (#190). Ausente ⇒ {@link DEFAULT_THEME} (comportamento
36
+ * atual, usado pelos testes); o `runTui` passa a paleta resolvida do settings.
37
+ */
38
+ initialPaletteId?: string;
39
+ /** Store para PERSISTIR a paleta escolhida (#190). Ausente ⇒ persistência é no-op. */
40
+ settings?: Pick<SettingsStore, 'setPaletteId'>;
41
+ }
42
+ /**
43
+ * Estado do tema/paleta (#190) — extraído para teste determinístico (sem depender
44
+ * do teclado): mantém a paleta ativa, resolve o {@link Theme} e expõe o
45
+ * {@link ThemeController} (preview troca ao vivo; select troca + persiste best-effort).
46
+ *
47
+ * @param initialPaletteId - Paleta inicial; `undefined` ⇒ {@link DEFAULT_THEME} (testes).
48
+ * @param settings - Store para persistir a paleta no `select` (opcional).
49
+ * @returns O `theme` resolvido + o `controller`.
50
+ */
51
+ export declare function useThemeControllerState(initialPaletteId?: string, settings?: Pick<SettingsStore, 'setPaletteId'>): {
52
+ theme: Theme;
53
+ controller: ThemeController;
54
+ };
55
+ export declare function App(props?: AppProps): import("react").JSX.Element;
@@ -0,0 +1,180 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Roteador de telas da TUI (M2-01, evoluído em M2-04 para pilha + casca
4
+ * visual global).
5
+ *
6
+ * `App` só instancia os providers (tema, pilha de navegação) — quem de fato
7
+ * consome o contexto e renderiza a casca visual (breadcrumb fixo, aviso de
8
+ * saída, tela atual) é `AppShell`, um componente à parte porque precisa
9
+ * estar DENTRO dos providers para poder chamar `useTheme()`/`useNavigation()`.
10
+ *
11
+ * Atalhos globais (qualquer tela): `q` sai, `Esc` volta (`pop()` da pilha,
12
+ * ver `navigation.tsx`), `Ctrl+C` segue o padrão de duas pressões (ver
13
+ * `hooks/use-exit-guard.ts`). `/` era reservado (no-op) para a busca antes
14
+ * da M2-07 existir; a semântica real agora vive na home (`screens/home.tsx`,
15
+ * que registra seu PRÓPRIO `useInput` para `/`) — o handler global permanece
16
+ * no-op aqui, o que automaticamente CONTINUA bloqueando `/` em qualquer tela
17
+ * que não seja a home (nenhuma outra tela trata essa tecla).
18
+ *
19
+ * Fix do review do PR #119: `Esc` numa tela `onboarding-*` enquanto um
20
+ * re-auth (M2-13, #36) está em andamento (`OnboardingContext.reAuth`) passa
21
+ * a significar ABANDONO explícito do re-login, em vez do `pop()` simples de
22
+ * sempre — sem isso, a `Promise` do `guard()` original
23
+ * (`hooks/use-auth-guard.ts`) ficaria pendurada para sempre. Nesse caso:
24
+ * `abortReAuth()` rejeita as pendências com `ReAuthAbortedError` e a
25
+ * navegação volta direto para a tela de ORIGEM do 401 (`popTo`,
26
+ * `navigation.tsx`) em vez de só desempilhar uma tela por vez.
27
+ *
28
+ * M2-07 (#30): `Esc` no campo de busca da home precisa fechar a busca em vez
29
+ * de desempilhar a home inteira — `consumeEscapeInterceptor()`
30
+ * (`hooks/use-escape-interceptor.ts`) é consultado ANTES do `pop()` padrão;
31
+ * se a home registrou um interceptor (busca aberta), ele é quem trata o Esc
32
+ * e o `pop()`/abandono de re-auth abaixo é pulado nesse ciclo.
33
+ */
34
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
35
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
36
+ import { Breadcrumb } from './components/breadcrumb.js';
37
+ import { TerminalSizeProvider, useTerminalHeight } from './hooks/use-terminal-width.js';
38
+ import { applyTerminalColors } from './terminal-colors.js';
39
+ import { ReAuthAbortedError } from './hooks/use-auth-guard.js';
40
+ import { consumeEscapeInterceptor } from './hooks/use-escape-interceptor.js';
41
+ import { useExitGuard } from './hooks/use-exit-guard.js';
42
+ import { useOnboardingCallbacks } from './hooks/use-onboarding-callbacks.js';
43
+ import { JobRegistryProvider } from './job-registry.js';
44
+ import { NavigationProvider, useNavigation, useNavigationStack } from './navigation.js';
45
+ import { HomeSelectionProvider } from './screens/home-selection.js';
46
+ import { LoadedIssueProvider } from './screens/loaded-issue-context.js';
47
+ import { OnboardingProvider, useOnboarding, } from './screens/onboarding/onboarding-context.js';
48
+ import { DEFAULT_PALETTE_ID, resolvePalette } from './palettes.js';
49
+ import { INITIAL_SCREEN, SCREENS } from './screen.js';
50
+ import { symbols } from './symbols.js';
51
+ import { DEFAULT_THEME, ThemeControllerProvider, ThemeProvider, useTheme, } from './theme.js';
52
+ /**
53
+ * Decide a ação de `Esc`: abandono do re-auth (fix do review #119) quando a
54
+ * tela atual é do fluxo de onboarding (`onboarding-*`) E há um `reAuth` em
55
+ * andamento; `pop()` simples em qualquer outro caso (comportamento original,
56
+ * M2-04).
57
+ */
58
+ export function resolveEscapeAction(current, reAuth) {
59
+ if (reAuth !== undefined && current.startsWith('onboarding-')) {
60
+ return { kind: 'abort-reauth', origin: reAuth.origin };
61
+ }
62
+ return { kind: 'pop' };
63
+ }
64
+ /**
65
+ * Estado do tema/paleta (#190) — extraído para teste determinístico (sem depender
66
+ * do teclado): mantém a paleta ativa, resolve o {@link Theme} e expõe o
67
+ * {@link ThemeController} (preview troca ao vivo; select troca + persiste best-effort).
68
+ *
69
+ * @param initialPaletteId - Paleta inicial; `undefined` ⇒ {@link DEFAULT_THEME} (testes).
70
+ * @param settings - Store para persistir a paleta no `select` (opcional).
71
+ * @returns O `theme` resolvido + o `controller`.
72
+ */
73
+ export function useThemeControllerState(initialPaletteId, settings) {
74
+ const [paletteId, setPaletteId] = useState(initialPaletteId);
75
+ const theme = paletteId !== undefined ? resolvePalette(paletteId).theme : DEFAULT_THEME;
76
+ const controller = useMemo(() => ({
77
+ paletteId: paletteId ?? DEFAULT_PALETTE_ID,
78
+ preview: (id) => setPaletteId(id),
79
+ select: async (id) => {
80
+ setPaletteId(id);
81
+ // Persistência best-effort: uma falha ao salvar não deve quebrar a TUI.
82
+ try {
83
+ await settings?.setPaletteId(id);
84
+ }
85
+ catch {
86
+ /* segue com a paleta aplicada nesta sessão */
87
+ }
88
+ },
89
+ }), [paletteId, settings]);
90
+ return { theme, controller };
91
+ }
92
+ export function App(props = {}) {
93
+ const { initialPaletteId, settings } = props;
94
+ const navigation = useNavigationStack(INITIAL_SCREEN);
95
+ const onboardingCallbacks = useOnboardingCallbacks();
96
+ // Paleta ativa (#190) — ver {@link useThemeControllerState}.
97
+ const { theme, controller } = useThemeControllerState(initialPaletteId, settings);
98
+ // Aplica as cores da paleta como default do terminal (OSC) sempre que ela muda
99
+ // (troca ao vivo na tela Aparência) — o fundo cobre a tela e todo texto sem cor
100
+ // explícita fica legível. Só em TTY real; nos testes (`ink-testing-library`) o
101
+ // stdout não é TTY, então não escreve nada.
102
+ const { stdout } = useStdout();
103
+ useEffect(() => {
104
+ if (stdout?.isTTY === true)
105
+ applyTerminalColors(stdout, theme);
106
+ }, [theme, stdout]);
107
+ return (_jsx(TerminalSizeProvider, { children: _jsx(ThemeProvider, { theme: theme, children: _jsx(ThemeControllerProvider, { value: controller, children: _jsx(NavigationProvider, { value: navigation, children: _jsx(OnboardingProvider, { callbacks: onboardingCallbacks, children: _jsx(HomeSelectionProvider, { children: _jsx(LoadedIssueProvider, { children: _jsx(JobRegistryProvider, { children: _jsx(AppShell, {}) }) }) }) }) }) }) }) }));
108
+ }
109
+ /**
110
+ * Casca visual do app: breadcrumb fixo + aviso de saída (quando armado) +
111
+ * tela atual. Só existe dentro dos providers de `App` — depende de
112
+ * `useTheme()`/`useNavigation()`.
113
+ */
114
+ function AppShell() {
115
+ const { exit } = useApp();
116
+ const { current, pop, popTo, stack } = useNavigation();
117
+ const { reAuth, abortReAuth } = useOnboarding();
118
+ const theme = useTheme();
119
+ const { armed } = useExitGuard(exit);
120
+ // Full-screen (#190): o app ocupa a altura TODA do terminal. `minHeight` (não
121
+ // `height` fixo) evita a amostragem do Yoga em listas longas (achado B2). As
122
+ // telas usam um espaçador (`flexGrow`) antes dos atalhos para ancorá-los no
123
+ // rodapé, estilo nano/nvim/tmux.
124
+ const rows = useTerminalHeight();
125
+ // Handler ESTÁVEL (refs + useCallback): identidade nova a cada render faz o
126
+ // useInput des/re-subscrever no efeito pós-commit, abrindo janelas em que
127
+ // teclas rápidas (Esc em sequência) se perdem — mesma classe de bug
128
+ // corrigida no TextInput.
129
+ const exitRef = useRef(exit);
130
+ exitRef.current = exit;
131
+ const popRef = useRef(pop);
132
+ popRef.current = pop;
133
+ const popToRef = useRef(popTo);
134
+ popToRef.current = popTo;
135
+ const currentRef = useRef(current);
136
+ currentRef.current = current;
137
+ const reAuthRef = useRef(reAuth);
138
+ reAuthRef.current = reAuth;
139
+ const abortReAuthRef = useRef(abortReAuth);
140
+ abortReAuthRef.current = abortReAuth;
141
+ const handleGlobalInput = useCallback((input, key) => {
142
+ if (input === 'q') {
143
+ exitRef.current();
144
+ return;
145
+ }
146
+ if (key.escape) {
147
+ // M2-07 (#30): a busca da home intercepta Esc para fechar o campo em
148
+ // vez de desempilhar a tela — consultado ANTES de qualquer outra
149
+ // semântica de Esc (pop/abandono de re-auth).
150
+ if (consumeEscapeInterceptor()) {
151
+ return;
152
+ }
153
+ // Fix do review #119: Esc numa tela de onboarding com um re-auth ativo
154
+ // é ABANDONO explícito — aborta as pendências (rejeita o(s) `guard()`
155
+ // original(is) com `ReAuthAbortedError`) e volta direto à tela de
156
+ // origem do 401, em vez do `pop()` simples de sempre.
157
+ const action = resolveEscapeAction(currentRef.current, reAuthRef.current);
158
+ if (action.kind === 'abort-reauth') {
159
+ abortReAuthRef.current(new ReAuthAbortedError());
160
+ popToRef.current(action.origin);
161
+ return;
162
+ }
163
+ popRef.current();
164
+ return;
165
+ }
166
+ if (input === '/') {
167
+ // Reason: reservado para a busca (M2-07, ainda sem tela) — no-op
168
+ // proposital para já travar a tecla e evitar que outra tela reaproveite
169
+ // "/" para algo diferente antes da tela de busca existir.
170
+ return;
171
+ }
172
+ }, []);
173
+ useInput(handleGlobalInput);
174
+ const Screen = SCREENS[current].component;
175
+ // Full-screen = alt-screen (runTui) + fundo/texto via OSC (cobrem a tela toda)
176
+ // + este container com a ALTURA do terminal (`minHeight`). O breadcrumb fica no
177
+ // topo e a tela ocupa o resto (`flexGrow`); cada tela ancora seus atalhos no
178
+ // rodapé com um espaçador `flexGrow` (estilo nano/nvim/tmux).
179
+ return (_jsxs(Box, { flexDirection: "column", minHeight: rows, children: [_jsx(Breadcrumb, { stack: stack }), armed ? (_jsx(Box, { paddingX: 1, marginBottom: 1, children: _jsxs(Text, { color: theme.warning, children: [symbols.warning, " Pressione Ctrl+C de novo para sair."] }) })) : null, _jsx(Box, { flexGrow: 1, flexDirection: "column", children: _jsx(Screen, {}) })] }));
180
+ }