redmine-context 1.0.0 → 1.2.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 (61) hide show
  1. package/README.md +20 -5
  2. package/dist/bundle/journal-detail.d.ts +89 -0
  3. package/dist/bundle/journal-detail.js +260 -0
  4. package/dist/bundle/markdown.d.ts +7 -0
  5. package/dist/bundle/markdown.js +21 -12
  6. package/dist/client/enumerations.d.ts +39 -0
  7. package/dist/client/enumerations.js +76 -0
  8. package/dist/client/index.d.ts +1 -0
  9. package/dist/client/index.js +1 -0
  10. package/dist/fetch-issue-bundle.js +17 -2
  11. package/dist/fetch-issue-search.d.ts +10 -0
  12. package/dist/fetch-issue-search.js +1 -1
  13. package/dist/fetch-last-issues.d.ts +96 -0
  14. package/dist/fetch-last-issues.js +120 -0
  15. package/dist/index.d.ts +6 -1
  16. package/dist/index.js +22 -2
  17. package/dist/normalize/issue.js +32 -3
  18. package/dist/surfaces/cli/commands.d.ts +17 -0
  19. package/dist/surfaces/cli/commands.js +105 -12
  20. package/dist/surfaces/cli/main.js +22 -3
  21. package/dist/surfaces/mcp/server.d.ts +22 -45
  22. package/dist/surfaces/mcp/server.js +67 -56
  23. package/dist/surfaces/mcp/tools.d.ts +109 -0
  24. package/dist/surfaces/mcp/tools.js +93 -0
  25. package/dist/surfaces/tui/app.d.ts +14 -0
  26. package/dist/surfaces/tui/app.js +40 -3
  27. package/dist/surfaces/tui/banner.d.ts +43 -0
  28. package/dist/surfaces/tui/banner.js +93 -0
  29. package/dist/surfaces/tui/components/gauge.d.ts +27 -0
  30. package/dist/surfaces/tui/components/gauge.js +53 -0
  31. package/dist/surfaces/tui/components/gradient-banner.d.ts +13 -0
  32. package/dist/surfaces/tui/components/gradient-banner.js +41 -0
  33. package/dist/surfaces/tui/components/text-input.js +23 -4
  34. package/dist/surfaces/tui/glyphs.d.ts +8 -0
  35. package/dist/surfaces/tui/glyphs.js +8 -0
  36. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +7 -1
  37. package/dist/surfaces/tui/hooks/use-issue-detail.js +15 -2
  38. package/dist/surfaces/tui/hooks/use-issue-search.d.ts +25 -2
  39. package/dist/surfaces/tui/hooks/use-issue-search.js +14 -2
  40. package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +8 -0
  41. package/dist/surfaces/tui/hooks/use-list-navigation.js +12 -1
  42. package/dist/surfaces/tui/hooks/use-my-issues.d.ts +9 -0
  43. package/dist/surfaces/tui/hooks/use-my-issues.js +6 -2
  44. package/dist/surfaces/tui/hooks/use-status-options.d.ts +38 -0
  45. package/dist/surfaces/tui/hooks/use-status-options.js +86 -0
  46. package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +16 -0
  47. package/dist/surfaces/tui/hooks/use-terminal-width.js +15 -0
  48. package/dist/surfaces/tui/hooks/use-typing-guard.d.ts +19 -0
  49. package/dist/surfaces/tui/hooks/use-typing-guard.js +58 -0
  50. package/dist/surfaces/tui/list-window.d.ts +39 -0
  51. package/dist/surfaces/tui/list-window.js +41 -0
  52. package/dist/surfaces/tui/screens/export.js +6 -2
  53. package/dist/surfaces/tui/screens/home.js +161 -33
  54. package/dist/surfaces/tui/screens/issue-detail.js +55 -16
  55. package/dist/surfaces/tui/screens/jobs.js +3 -2
  56. package/dist/surfaces/tui/screens/welcome.js +9 -1
  57. package/dist/surfaces/tui/status-color.d.ts +21 -0
  58. package/dist/surfaces/tui/status-color.js +33 -0
  59. package/dist/surfaces/tui/wrap.d.ts +38 -0
  60. package/dist/surfaces/tui/wrap.js +82 -0
  61. package/package.json +1 -1
@@ -16,45 +16,9 @@
16
16
  */
17
17
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
18
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
19
+ import { type GetAttachmentTextArgs, type GetIssueContextArgs, type GetLastArgs, type SearchIssuesArgs } from './tools.js';
19
20
  import * as core from '../../index.js';
20
21
  import type { AttachmentTextResult, FetchAttachmentTextOptions, resolveApiKey } from '../../index.js';
21
- /** Formato aceito pela tool MCP (nomes amigáveis expostos ao cliente). */
22
- export type McpFormat = 'markdown' | 'json';
23
- /** Argumentos da tool `get_issue_context` já validados pelo schema zod. */
24
- export interface GetIssueContextArgs {
25
- /** Identificador numérico da issue no Redmine. */
26
- issue_id: number;
27
- /** Formato de saída: `markdown` (padrão) ou `json`. */
28
- format?: McpFormat | undefined;
29
- /**
30
- * Extrai o texto dos anexos de imagem (OCR) e o embute no bundle. Default:
31
- * `false` — a extração adiciona latência (download + OCR por anexo). O M4 trará
32
- * o modo cache-first/processing assíncrono; no M3 a extração é síncrona.
33
- */
34
- extract_attachments?: boolean | undefined;
35
- }
36
- /** Argumentos da tool `get_attachment_text` já validados pelo schema zod. */
37
- export interface GetAttachmentTextArgs {
38
- /** Identificador numérico da issue que contém o anexo. */
39
- issue_id: number;
40
- /** Identificador numérico do anexo cujo texto será extraído. */
41
- attachment_id: number;
42
- }
43
- /** Argumentos da tool `search_issues` já validados pelo schema zod. */
44
- export interface SearchIssuesArgs {
45
- /** Termo full-text opcional (`/search.json`, best-effort). */
46
- query?: string | undefined;
47
- /** Filtro `project_id`. */
48
- project_id?: number | undefined;
49
- /** Filtro `status_id` (`'open'`, `'closed'`, `'*'` ou um id). */
50
- status_id?: number | string | undefined;
51
- /** Filtro `assigned_to_id` (um id ou `'me'`). */
52
- assigned_to_id?: number | string | undefined;
53
- /** Filtro `updated_on` no formato do Redmine (ex.: `>=2026-01-01`). */
54
- updated_on?: string | undefined;
55
- /** Máximo de resultados. Default: {@link SEARCH_DEFAULT_LIMIT}. */
56
- limit?: number | undefined;
57
- }
58
22
  /**
59
23
  * Dependências injetáveis do server MCP — permitem testar o handler sem tocar o
60
24
  * processo real nem a rede. Os defaults (ver {@link defaultMcpDeps}) apontam para
@@ -65,6 +29,8 @@ export interface McpServerDeps {
65
29
  fetchIssueBundle: typeof core.fetchIssueBundle;
66
30
  /** Orquestração de busca (filtros + full-text best-effort) do core. */
67
31
  searchIssues: typeof core.fetchIssueSearch;
32
+ /** Orquestração das últimas issues (ordem + bundle completo) do core. */
33
+ fetchLastIssues: typeof core.fetchLastIssues;
68
34
  /**
69
35
  * Orquestração get → normalize → extração CACHE-FIRST de UM anexo (M4-11 #70):
70
36
  * devolve o texto já cacheado na hora e `processing` (sem bloquear) para mídia
@@ -95,14 +61,6 @@ export interface McpServerDeps {
95
61
  /** Sink de diagnóstico (progresso/erros) — SEMPRE stderr no stdio transport. */
96
62
  log?: (message: string) => void;
97
63
  }
98
- /** Nome canônico da tool de contexto de issue. */
99
- export declare const TOOL_NAME = "get_issue_context";
100
- /** Nome canônico da tool de busca de issues. */
101
- export declare const SEARCH_TOOL_NAME = "search_issues";
102
- /** Nome canônico da tool de texto de anexo. */
103
- export declare const ATTACHMENT_TOOL_NAME = "get_attachment_text";
104
- /** Limite default de resultados da tool `search_issues` (documentado no schema). */
105
- export declare const SEARCH_DEFAULT_LIMIT = 25;
106
64
  /**
107
65
  * Cria o handler da tool `get_issue_context`, testável isoladamente.
108
66
  *
@@ -132,6 +90,24 @@ export declare function createGetIssueContextHandler(deps: McpServerDeps): (args
132
90
  * const result = await handler({ query: 'timeout', project_id: 5 });
133
91
  */
134
92
  export declare function createSearchIssuesHandler(deps: McpServerDeps): (args: SearchIssuesArgs) => Promise<CallToolResult>;
93
+ /**
94
+ * Cria o handler da tool `get_last`, testável isoladamente.
95
+ *
96
+ * Resolve a instância/credencial da env (nunca de argumentos) e delega à
97
+ * orquestração `fetchLastIssues`: ordena por `updated`/`created`/`priority` e
98
+ * devolve o BUNDLE COMPLETO das mais recentes — o atalho de um passo para
99
+ * "me dá a última issue", sem exigir que o cliente descubra o id antes.
100
+ *
101
+ * Usa `cacheFirst: true` como as demais tools: responde na hora com o texto de
102
+ * anexo já cacheado e marca o restante como `processing`, sem bloquear no OCR.
103
+ *
104
+ * @param deps - Ver {@link McpServerDeps}.
105
+ * @returns Função assíncrona que recebe os argumentos e devolve um CallToolResult.
106
+ * @example
107
+ * const handler = createGetLastHandler(defaultMcpDeps());
108
+ * const result = await handler({ order: 'priority', count: 3 });
109
+ */
110
+ export declare function createGetLastHandler(deps: McpServerDeps): (args: GetLastArgs) => Promise<CallToolResult>;
135
111
  /**
136
112
  * Cria o handler da tool `get_attachment_text`, testável isoladamente.
137
113
  *
@@ -169,3 +145,4 @@ export declare function defaultMcpDeps(): McpServerDeps;
169
145
  * @returns Promise que resolve quando o transporte encerra.
170
146
  */
171
147
  export declare function runStdioServer(overrides?: Partial<McpServerDeps>): Promise<void>;
148
+ export { ATTACHMENT_TOOL_NAME, LAST_TOOL_NAME, SEARCH_DEFAULT_LIMIT, SEARCH_TOOL_NAME, TOOL_NAME, type GetAttachmentTextArgs, type GetIssueContextArgs, type GetLastArgs, type McpFormat, type SearchIssuesArgs, } from './tools.js';
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
18
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
- import { z } from 'zod';
19
+ import { ATTACHMENT_INPUT_SCHEMA, ATTACHMENT_TOOL_NAME, INPUT_SCHEMA, LAST_INPUT_SCHEMA, LAST_TOOL_NAME, SEARCH_DEFAULT_LIMIT, SEARCH_INPUT_SCHEMA, SEARCH_TOOL_NAME, TOOL_NAME, } from './tools.js';
20
20
  import * as core from '../../index.js';
21
21
  import { fenceBlock } from '../../index.js';
22
22
  /** Interpreta `REDMINE_INSECURE` (`1`/`true`, case-insensitive) como boolean. */
@@ -24,61 +24,6 @@ function parseInsecure(env) {
24
24
  const raw = env.REDMINE_INSECURE;
25
25
  return raw !== undefined && /^(1|true)$/i.test(raw.trim());
26
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
27
  /** Extrai uma mensagem legível de um erro desconhecido. */
83
28
  function messageOf(error) {
84
29
  return error instanceof Error ? error.message : String(error);
@@ -276,6 +221,60 @@ export function createSearchIssuesHandler(deps) {
276
221
  }
277
222
  };
278
223
  }
224
+ /**
225
+ * Cria o handler da tool `get_last`, testável isoladamente.
226
+ *
227
+ * Resolve a instância/credencial da env (nunca de argumentos) e delega à
228
+ * orquestração `fetchLastIssues`: ordena por `updated`/`created`/`priority` e
229
+ * devolve o BUNDLE COMPLETO das mais recentes — o atalho de um passo para
230
+ * "me dá a última issue", sem exigir que o cliente descubra o id antes.
231
+ *
232
+ * Usa `cacheFirst: true` como as demais tools: responde na hora com o texto de
233
+ * anexo já cacheado e marca o restante como `processing`, sem bloquear no OCR.
234
+ *
235
+ * @param deps - Ver {@link McpServerDeps}.
236
+ * @returns Função assíncrona que recebe os argumentos e devolve um CallToolResult.
237
+ * @example
238
+ * const handler = createGetLastHandler(defaultMcpDeps());
239
+ * const result = await handler({ order: 'priority', count: 3 });
240
+ */
241
+ export function createGetLastHandler(deps) {
242
+ return async (args) => {
243
+ const resolved = await resolveInstance(deps);
244
+ if (!isResolved(resolved))
245
+ return resolved;
246
+ const { baseUrl, apiKey } = resolved;
247
+ const format = args.format === 'json' ? 'json' : 'md';
248
+ try {
249
+ let content;
250
+ for await (const event of deps.fetchLastIssues({
251
+ baseUrl,
252
+ apiKey,
253
+ format,
254
+ order: args.order,
255
+ count: args.count,
256
+ toolVersion: deps.toolVersion,
257
+ insecure: deps.insecure ?? false,
258
+ extractAttachments: args.extract_attachments ?? false,
259
+ cacheFirst: true,
260
+ })) {
261
+ if (event.kind === 'progress') {
262
+ deps.log?.(event.message);
263
+ }
264
+ else {
265
+ content = event.value.content;
266
+ }
267
+ }
268
+ if (content === undefined) {
269
+ return errorResult('A operação não produziu um bundle.');
270
+ }
271
+ return textResult(content);
272
+ }
273
+ catch (error) {
274
+ return errorResult(typedSearchErrorMessage(error));
275
+ }
276
+ };
277
+ }
279
278
  /**
280
279
  * Traduz erros da extração de anexo em mensagens claras e tipadas.
281
280
  *
@@ -370,6 +369,7 @@ export function createMcpServer(deps) {
370
369
  const handler = createGetIssueContextHandler(deps);
371
370
  const searchHandler = createSearchIssuesHandler(deps);
372
371
  const attachmentHandler = createGetAttachmentTextHandler(deps);
372
+ const lastHandler = createGetLastHandler(deps);
373
373
  server.registerTool(TOOL_NAME, {
374
374
  title: 'Contexto de issue do Redmine',
375
375
  description: 'Busca uma issue na instância Redmine configurada (REDMINE_URL) e retorna seu contexto completo empacotado. Read-only.',
@@ -388,6 +388,12 @@ export function createMcpServer(deps) {
388
388
  inputSchema: ATTACHMENT_INPUT_SCHEMA,
389
389
  annotations: { readOnlyHint: true, openWorldHint: true },
390
390
  }, (args) => attachmentHandler(args));
391
+ server.registerTool(LAST_TOOL_NAME, {
392
+ title: 'Últimas issues do Redmine',
393
+ description: 'Retorna o contexto completo das issues mais recentes da instância configurada (REDMINE_URL), ordenadas por updated (padrão), created ou priority. Atalho de um passo quando o id ainda não é conhecido — considera apenas issues abertas; use search_issues para filtros (projeto, status, responsável). Read-only.',
394
+ inputSchema: LAST_INPUT_SCHEMA,
395
+ annotations: { readOnlyHint: true, openWorldHint: true },
396
+ }, (args) => lastHandler(args));
391
397
  return server;
392
398
  }
393
399
  /** Constrói as dependências default apontando para o core e o processo reais. */
@@ -395,6 +401,7 @@ export function defaultMcpDeps() {
395
401
  return {
396
402
  fetchIssueBundle: core.fetchIssueBundle,
397
403
  searchIssues: core.fetchIssueSearch,
404
+ fetchLastIssues: core.fetchLastIssues,
398
405
  fetchAttachmentText: core.fetchAttachmentTextCacheFirst,
399
406
  resolveApiKey: core.resolveApiKey,
400
407
  env: process.env,
@@ -425,3 +432,7 @@ export async function runStdioServer(overrides = {}) {
425
432
  });
426
433
  }
427
434
  /* c8 ignore stop */
435
+ // Contrato de entrada das tools (nomes, tipos e schemas) — vive em `./tools.ts`
436
+ // desde a extração da RULES #24. Reexportado aqui para que os consumidores
437
+ // (CLI, testes) continuem tendo `./server.js` como porta única da superfície MCP.
438
+ export { ATTACHMENT_TOOL_NAME, LAST_TOOL_NAME, SEARCH_DEFAULT_LIMIT, SEARCH_TOOL_NAME, TOOL_NAME, } from './tools.js';
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Contrato de ENTRADA das tools MCP: nomes canônicos, tipos de argumentos e
3
+ * schemas zod.
4
+ *
5
+ * Extraído de `./server.ts` (RULES #24 — arquivo não-teste perto de 700 linhas):
6
+ * o server passa a conter apenas o comportamento (resolução de instância,
7
+ * handlers e registro), enquanto a superfície declarativa — o que cada tool
8
+ * aceita — vive aqui. Um só motivo para mudar cada arquivo.
9
+ *
10
+ * Segurança: nenhum schema aceita URL/host — a instância vem sempre da config do
11
+ * processo (ver `resolveInstance` em `./server.ts`).
12
+ */
13
+ import { z } from 'zod';
14
+ import * as core from '../../index.js';
15
+ /** Formato aceito pelas tools MCP (nomes amigáveis expostos ao cliente). */
16
+ export type McpFormat = 'markdown' | 'json';
17
+ /** Nome canônico da tool de contexto de issue. */
18
+ export declare const TOOL_NAME = "get_issue_context";
19
+ /** Nome canônico da tool de busca de issues. */
20
+ export declare const SEARCH_TOOL_NAME = "search_issues";
21
+ /** Nome canônico da tool de texto de anexo. */
22
+ export declare const ATTACHMENT_TOOL_NAME = "get_attachment_text";
23
+ /** Nome canônico da tool das últimas issues. */
24
+ export declare const LAST_TOOL_NAME = "get_last";
25
+ /** Limite default de resultados da tool `search_issues` (documentado no schema). */
26
+ export declare const SEARCH_DEFAULT_LIMIT = 25;
27
+ /** Argumentos da tool `get_issue_context` já validados pelo schema zod. */
28
+ export interface GetIssueContextArgs {
29
+ /** Identificador numérico da issue no Redmine. */
30
+ issue_id: number;
31
+ /** Formato de saída: `markdown` (padrão) ou `json`. */
32
+ format?: McpFormat | undefined;
33
+ /**
34
+ * Extrai o texto dos anexos de imagem (OCR) e o embute no bundle. Default:
35
+ * `false` — a extração adiciona latência (download + OCR por anexo). O M4 trará
36
+ * o modo cache-first/processing assíncrono; no M3 a extração é síncrona.
37
+ */
38
+ extract_attachments?: boolean | undefined;
39
+ }
40
+ /** Argumentos da tool `get_attachment_text` já validados pelo schema zod. */
41
+ export interface GetAttachmentTextArgs {
42
+ /** Identificador numérico da issue que contém o anexo. */
43
+ issue_id: number;
44
+ /** Identificador numérico do anexo cujo texto será extraído. */
45
+ attachment_id: number;
46
+ }
47
+ /** Argumentos da tool `search_issues` já validados pelo schema zod. */
48
+ export interface SearchIssuesArgs {
49
+ /** Termo full-text opcional (`/search.json`, best-effort). */
50
+ query?: string | undefined;
51
+ /** Filtro `project_id`. */
52
+ project_id?: number | undefined;
53
+ /** Filtro `status_id` (`'open'`, `'closed'`, `'*'` ou um id). */
54
+ status_id?: number | string | undefined;
55
+ /** Filtro `assigned_to_id` (um id ou `'me'`). */
56
+ assigned_to_id?: number | string | undefined;
57
+ /** Filtro `updated_on` no formato do Redmine (ex.: `>=2026-01-01`). */
58
+ updated_on?: string | undefined;
59
+ /** Máximo de resultados. Default: {@link SEARCH_DEFAULT_LIMIT}. */
60
+ limit?: number | undefined;
61
+ }
62
+ /** Argumentos da tool `get_last` já validados pelo schema zod. */
63
+ export interface GetLastArgs {
64
+ /** Critério de ordenação. Default: `updated`. */
65
+ order?: core.LastIssuesOrder | undefined;
66
+ /** Quantas issues empacotar. Default: 1; teto {@link core.LAST_MAX_COUNT}. */
67
+ count?: number | undefined;
68
+ /** Formato de saída: `markdown` (padrão) ou `json`. */
69
+ format?: McpFormat | undefined;
70
+ /** Extrai o texto (OCR) dos anexos e o embute nos bundles. Default: `false`. */
71
+ extract_attachments?: boolean | undefined;
72
+ }
73
+ /** Schema zod da tool `get_issue_context` (sem URL/host: a instância vem da env). */
74
+ export declare const INPUT_SCHEMA: {
75
+ readonly issue_id: z.ZodNumber;
76
+ readonly format: z.ZodOptional<z.ZodEnum<{
77
+ json: "json";
78
+ markdown: "markdown";
79
+ }>>;
80
+ readonly extract_attachments: z.ZodOptional<z.ZodBoolean>;
81
+ };
82
+ /** Schema zod da tool `get_attachment_text` (read-only, sem URL/host). */
83
+ export declare const ATTACHMENT_INPUT_SCHEMA: {
84
+ readonly issue_id: z.ZodNumber;
85
+ readonly attachment_id: z.ZodNumber;
86
+ };
87
+ /** Schema zod da tool `search_issues` (read-only, sem URL/host). */
88
+ export declare const SEARCH_INPUT_SCHEMA: {
89
+ readonly query: z.ZodOptional<z.ZodString>;
90
+ readonly project_id: z.ZodOptional<z.ZodNumber>;
91
+ readonly status_id: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>;
92
+ readonly assigned_to_id: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>;
93
+ readonly updated_on: z.ZodOptional<z.ZodString>;
94
+ readonly limit: z.ZodOptional<z.ZodNumber>;
95
+ };
96
+ /** Schema zod da tool `get_last` (read-only, sem URL/host). */
97
+ export declare const LAST_INPUT_SCHEMA: {
98
+ readonly order: z.ZodOptional<z.ZodEnum<{
99
+ priority: "priority";
100
+ updated: "updated";
101
+ created: "created";
102
+ }>>;
103
+ readonly count: z.ZodOptional<z.ZodNumber>;
104
+ readonly format: z.ZodOptional<z.ZodEnum<{
105
+ json: "json";
106
+ markdown: "markdown";
107
+ }>>;
108
+ readonly extract_attachments: z.ZodOptional<z.ZodBoolean>;
109
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Contrato de ENTRADA das tools MCP: nomes canônicos, tipos de argumentos e
3
+ * schemas zod.
4
+ *
5
+ * Extraído de `./server.ts` (RULES #24 — arquivo não-teste perto de 700 linhas):
6
+ * o server passa a conter apenas o comportamento (resolução de instância,
7
+ * handlers e registro), enquanto a superfície declarativa — o que cada tool
8
+ * aceita — vive aqui. Um só motivo para mudar cada arquivo.
9
+ *
10
+ * Segurança: nenhum schema aceita URL/host — a instância vem sempre da config do
11
+ * processo (ver `resolveInstance` em `./server.ts`).
12
+ */
13
+ import { z } from 'zod';
14
+ import * as core from '../../index.js';
15
+ /** Nome canônico da tool de contexto de issue. */
16
+ export const TOOL_NAME = 'get_issue_context';
17
+ /** Nome canônico da tool de busca de issues. */
18
+ export const SEARCH_TOOL_NAME = 'search_issues';
19
+ /** Nome canônico da tool de texto de anexo. */
20
+ export const ATTACHMENT_TOOL_NAME = 'get_attachment_text';
21
+ /** Nome canônico da tool das últimas issues. */
22
+ export const LAST_TOOL_NAME = 'get_last';
23
+ /** Limite default de resultados da tool `search_issues` (documentado no schema). */
24
+ export const SEARCH_DEFAULT_LIMIT = 25;
25
+ /** Teto de resultados aceito pela tool `search_issues`. */
26
+ const SEARCH_MAX_LIMIT = 100;
27
+ /** Schema zod da tool `get_issue_context` (sem URL/host: a instância vem da env). */
28
+ export const INPUT_SCHEMA = {
29
+ issue_id: z.number().int().positive().describe('Identificador numérico da issue no Redmine'),
30
+ format: z
31
+ .enum(['markdown', 'json'])
32
+ .optional()
33
+ .describe("Formato de saída: 'markdown' (padrão) ou 'json'"),
34
+ extract_attachments: z
35
+ .boolean()
36
+ .optional()
37
+ .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.'),
38
+ };
39
+ /** Schema zod da tool `get_attachment_text` (read-only, sem URL/host). */
40
+ export const ATTACHMENT_INPUT_SCHEMA = {
41
+ issue_id: z.number().int().positive().describe('Identificador numérico da issue que contém o anexo'),
42
+ attachment_id: z.number().int().positive().describe('Identificador numérico do anexo a extrair'),
43
+ };
44
+ /** Schema zod da tool `search_issues` (read-only, sem URL/host). */
45
+ export const SEARCH_INPUT_SCHEMA = {
46
+ query: z
47
+ .string()
48
+ .min(1)
49
+ .optional()
50
+ .describe('Termo full-text via /search.json (best-effort). Se a busca falhar, degrada para os filtros estruturados com aviso.'),
51
+ project_id: z.number().int().positive().optional().describe('Filtro estruturado project_id'),
52
+ status_id: z
53
+ .union([z.number().int(), z.string()])
54
+ .optional()
55
+ .describe("Filtro status_id: um id, 'open', 'closed' ou '*'"),
56
+ assigned_to_id: z
57
+ .union([z.number().int(), z.string()])
58
+ .optional()
59
+ .describe("Filtro assigned_to_id: um id ou 'me'"),
60
+ updated_on: z
61
+ .string()
62
+ .optional()
63
+ .describe('Filtro updated_on no formato do Redmine (ex.: >=2026-01-01, <=2026-12-31)'),
64
+ limit: z
65
+ .number()
66
+ .int()
67
+ .positive()
68
+ .max(SEARCH_MAX_LIMIT)
69
+ .optional()
70
+ .describe(`Máximo de resultados paginados (default ${SEARCH_DEFAULT_LIMIT}, teto ${SEARCH_MAX_LIMIT})`),
71
+ };
72
+ /** Schema zod da tool `get_last` (read-only, sem URL/host). */
73
+ export const LAST_INPUT_SCHEMA = {
74
+ order: z
75
+ .enum(['updated', 'created', 'priority'])
76
+ .optional()
77
+ .describe(`Critério de ordenação: 'updated' (padrão — mexida mais recente), 'created' (entrada mais recente) ou 'priority' (mais urgente, desempatando pela mais recente)`),
78
+ count: z
79
+ .number()
80
+ .int()
81
+ .positive()
82
+ .max(core.LAST_MAX_COUNT)
83
+ .optional()
84
+ .describe(`Quantas issues empacotar (default ${core.LAST_DEFAULT_COUNT}, teto ${core.LAST_MAX_COUNT}). Cada item é um bundle COMPLETO — prefira search_issues para visões amplas.`),
85
+ format: z
86
+ .enum(['markdown', 'json'])
87
+ .optional()
88
+ .describe("Formato de saída: 'markdown' (padrão) ou 'json' (sempre um array)"),
89
+ extract_attachments: z
90
+ .boolean()
91
+ .optional()
92
+ .describe('Extrai o texto (OCR) dos anexos de imagem e o embute nos bundles. Default: false (adiciona latência de download+OCR).'),
93
+ };
@@ -9,6 +9,20 @@ import { type Theme, type ThemeController } from './theme.js';
9
9
  * Ink nem depender de uma tela de dados real (#29+) para chegar no estado de
10
10
  * re-auth ativo.
11
11
  */
12
+ /**
13
+ * Estilo da moldura da aplicação conforme o suporte a Unicode.
14
+ *
15
+ * `round` desenha com box-drawing (`╭ ─ │ ╯`); no terminal legado do Windows
16
+ * isso vira mojibake, do mesmo jeito que os frames braille do spinner (M5-09,
17
+ * #84). `classic` é o fallback ASCII do próprio Ink (`+ - |`).
18
+ *
19
+ * Função pura (em vez de só a constante) para o fallback ser testável sem
20
+ * depender do ambiente real — mesmo padrão de `./glyphs.ts`.
21
+ *
22
+ * @param unicode - `true` quando o terminal renderiza box-drawing.
23
+ * @returns O `borderStyle` a passar ao `Box` do Ink.
24
+ */
25
+ export declare function borderStyleFor(unicode: boolean): 'round' | 'classic';
12
26
  export type EscapeAction = {
13
27
  kind: 'abort-reauth';
14
28
  origin: ScreenName;
@@ -34,12 +34,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
34
34
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
35
35
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
36
36
  import { Breadcrumb } from './components/breadcrumb.js';
37
- import { TerminalSizeProvider, useTerminalHeight } from './hooks/use-terminal-width.js';
37
+ import { isUnicodeSupported } from './glyphs.js';
38
+ import { TerminalHeightProvider, TerminalSizeProvider, useTerminalHeight, } from './hooks/use-terminal-width.js';
38
39
  import { applyTerminalColors } from './terminal-colors.js';
39
40
  import { ReAuthAbortedError } from './hooks/use-auth-guard.js';
40
41
  import { consumeEscapeInterceptor } from './hooks/use-escape-interceptor.js';
41
42
  import { useExitGuard } from './hooks/use-exit-guard.js';
42
43
  import { useOnboardingCallbacks } from './hooks/use-onboarding-callbacks.js';
44
+ import { isTyping } from './hooks/use-typing-guard.js';
43
45
  import { JobRegistryProvider } from './job-registry.js';
44
46
  import { NavigationProvider, useNavigation, useNavigationStack } from './navigation.js';
45
47
  import { HomeSelectionProvider } from './screens/home-selection.js';
@@ -49,6 +51,33 @@ import { DEFAULT_PALETTE_ID, resolvePalette } from './palettes.js';
49
51
  import { INITIAL_SCREEN, SCREENS } from './screen.js';
50
52
  import { symbols } from './symbols.js';
51
53
  import { DEFAULT_THEME, ThemeControllerProvider, ThemeProvider, useTheme, } from './theme.js';
54
+ /**
55
+ * O que `Esc` deve fazer dado o estado atual — extraída como função pura
56
+ * (fix do review #119) para poder ser testada isoladamente
57
+ * (`tests/surfaces/tui/app.test.tsx`) sem precisar montar toda a árvore do
58
+ * Ink nem depender de uma tela de dados real (#29+) para chegar no estado de
59
+ * re-auth ativo.
60
+ */
61
+ /**
62
+ * Estilo da moldura da aplicação conforme o suporte a Unicode.
63
+ *
64
+ * `round` desenha com box-drawing (`╭ ─ │ ╯`); no terminal legado do Windows
65
+ * isso vira mojibake, do mesmo jeito que os frames braille do spinner (M5-09,
66
+ * #84). `classic` é o fallback ASCII do próprio Ink (`+ - |`).
67
+ *
68
+ * Função pura (em vez de só a constante) para o fallback ser testável sem
69
+ * depender do ambiente real — mesmo padrão de `./glyphs.ts`.
70
+ *
71
+ * @param unicode - `true` quando o terminal renderiza box-drawing.
72
+ * @returns O `borderStyle` a passar ao `Box` do Ink.
73
+ */
74
+ export function borderStyleFor(unicode) {
75
+ return unicode ? 'round' : 'classic';
76
+ }
77
+ /** Estilo resolvido para o ambiente atual — decidido uma vez, no import. */
78
+ const BORDER_STYLE = borderStyleFor(isUnicodeSupported());
79
+ /** Linhas consumidas pela moldura (topo + base). */
80
+ const BORDER_ROWS = 2;
52
81
  /**
53
82
  * Decide a ação de `Esc`: abandono do re-auth (fix do review #119) quando a
54
83
  * tela atual é do fluxo de onboarding (`onboarding-*`) E há um `reAuth` em
@@ -139,7 +168,10 @@ function AppShell() {
139
168
  const abortReAuthRef = useRef(abortReAuth);
140
169
  abortReAuthRef.current = abortReAuth;
141
170
  const handleGlobalInput = useCallback((input, key) => {
142
- if (input === 'q') {
171
+ // `q` sai FORA de campo de texto: o Ink entrega a tecla a todos os
172
+ // handlers, então sem esta guarda digitar uma URL com "q" (ou uma senha)
173
+ // fecharia a TUI no meio do onboarding (ver ./hooks/use-typing-guard.ts).
174
+ if (input === 'q' && !isTyping()) {
143
175
  exitRef.current();
144
176
  return;
145
177
  }
@@ -176,5 +208,10 @@ function AppShell() {
176
208
  // + este container com a ALTURA do terminal (`minHeight`). O breadcrumb fica no
177
209
  // topo e a tela ocupa o resto (`flexGrow`); cada tela ancora seus atalhos no
178
210
  // 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, {}) })] }));
211
+ return (
212
+ // Moldura única da aplicação (#190 — pacote estético): a borda vive AQUI e
213
+ // não nas telas, então nenhuma tela precisa saber que existe uma. As duas
214
+ // linhas da borda saem do minHeight para o conteúdo não estourar a altura do
215
+ // terminal (o que empurraria o topo para fora no modo full-screen).
216
+ _jsxs(Box, { flexDirection: "column", minHeight: Math.max(1, rows - BORDER_ROWS), borderStyle: BORDER_STYLE, borderColor: theme.border, 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(TerminalHeightProvider, { height: Math.max(1, rows - BORDER_ROWS), children: _jsx(Screen, {}) }) })] }));
180
217
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Arte ASCII do banner da tela de abertura (#190 — pacote estético).
3
+ *
4
+ * As três variantes são CONSTANTES, não geradas em runtime: o texto é sempre
5
+ * `redmine-context`, então trazer uma dependência de fontes (figlet/cfonts) para
6
+ * recalcular a mesma string a cada boot não se paga — custaria peso no pacote e
7
+ * tempo de start. Para regenerar (fonte `ANSI Shadow` / `Small` do figlet):
8
+ *
9
+ * npx figlet -f "ANSI Shadow" redmine-context
10
+ * npx figlet -f "ANSI Shadow" redmine ; npx figlet -f "ANSI Shadow" context
11
+ * npx figlet -f Small redmine-context
12
+ *
13
+ * A seleção é PURA ({@link selectBanner}) e depende de dois sinais que a TUI já
14
+ * possui: a largura do terminal (`../hooks/use-terminal-width.js`) e o suporte a
15
+ * Unicode (`../glyphs.js`) — o mesmo sinal que degrada os frames braille do
16
+ * spinner no terminal legado do Windows (M5-09, #84). Sem essa degradação, os
17
+ * blocos `█`/`╗` do ANSI Shadow virariam mojibake.
18
+ */
19
+ /** Variante escolhida por {@link selectBanner}. */
20
+ export type BannerVariant = 'wide' | 'stacked' | 'ascii' | 'plain';
21
+ /**
22
+ * Escolhe a variante do banner para o terminal atual.
23
+ *
24
+ * Ordem de preferência, sempre respeitando a largura disponível:
25
+ * `wide` (uma linha, Unicode) → `stacked` (duas linhas, Unicode) → `ascii`
26
+ * (ASCII puro) → `plain` (sem arte; a tela cai no nome em texto).
27
+ *
28
+ * @param width - Colunas disponíveis (ver `useTerminalWidth`).
29
+ * @param unicode - `true` quando o terminal renderiza os blocos do ANSI Shadow.
30
+ * @returns A variante a renderizar.
31
+ * @example
32
+ * selectBanner(140, true) // 'wide'
33
+ * selectBanner(80, true) // 'stacked' (não cabe a wide)
34
+ * selectBanner(80, false) // 'ascii' (sem Unicode)
35
+ */
36
+ export declare function selectBanner(width: number, unicode: boolean): BannerVariant;
37
+ /**
38
+ * Linhas da variante escolhida.
39
+ *
40
+ * @param variant - Variante de {@link selectBanner}.
41
+ * @returns As linhas da arte; vazio para `plain`.
42
+ */
43
+ export declare function bannerLines(variant: BannerVariant): readonly string[];