redmine-context 1.0.0 → 1.1.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 (40) 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 +240 -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-last-issues.d.ts +96 -0
  12. package/dist/fetch-last-issues.js +120 -0
  13. package/dist/index.d.ts +5 -1
  14. package/dist/index.js +22 -2
  15. package/dist/normalize/issue.js +32 -3
  16. package/dist/surfaces/cli/commands.d.ts +17 -0
  17. package/dist/surfaces/cli/commands.js +105 -12
  18. package/dist/surfaces/cli/main.js +22 -3
  19. package/dist/surfaces/mcp/server.d.ts +22 -45
  20. package/dist/surfaces/mcp/server.js +67 -56
  21. package/dist/surfaces/mcp/tools.d.ts +109 -0
  22. package/dist/surfaces/mcp/tools.js +93 -0
  23. package/dist/surfaces/tui/app.d.ts +14 -0
  24. package/dist/surfaces/tui/app.js +32 -1
  25. package/dist/surfaces/tui/banner.d.ts +43 -0
  26. package/dist/surfaces/tui/banner.js +93 -0
  27. package/dist/surfaces/tui/components/gauge.d.ts +27 -0
  28. package/dist/surfaces/tui/components/gauge.js +53 -0
  29. package/dist/surfaces/tui/components/gradient-banner.d.ts +13 -0
  30. package/dist/surfaces/tui/components/gradient-banner.js +41 -0
  31. package/dist/surfaces/tui/glyphs.d.ts +4 -0
  32. package/dist/surfaces/tui/glyphs.js +4 -0
  33. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +7 -1
  34. package/dist/surfaces/tui/hooks/use-issue-detail.js +15 -2
  35. package/dist/surfaces/tui/screens/issue-detail.js +28 -7
  36. package/dist/surfaces/tui/screens/jobs.js +3 -2
  37. package/dist/surfaces/tui/screens/welcome.js +9 -1
  38. package/dist/surfaces/tui/wrap.d.ts +38 -0
  39. package/dist/surfaces/tui/wrap.js +82 -0
  40. package/package.json +1 -1
@@ -0,0 +1,93 @@
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
+ const WIDE_LINES = [
20
+ '██████╗ ███████╗██████╗ ███╗ ███╗██╗███╗ ██╗███████╗ ██████╗ ██████╗ ███╗ ██╗████████╗███████╗██╗ ██╗████████╗',
21
+ '██╔══██╗██╔════╝██╔══██╗████╗ ████║██║████╗ ██║██╔════╝ ██╔════╝██╔═══██╗████╗ ██║╚══██╔══╝██╔════╝╚██╗██╔╝╚══██╔══╝',
22
+ '██████╔╝█████╗ ██║ ██║██╔████╔██║██║██╔██╗ ██║█████╗█████╗██║ ██║ ██║██╔██╗ ██║ ██║ █████╗ ╚███╔╝ ██║ ',
23
+ '██╔══██╗██╔══╝ ██║ ██║██║╚██╔╝██║██║██║╚██╗██║██╔══╝╚════╝██║ ██║ ██║██║╚██╗██║ ██║ ██╔══╝ ██╔██╗ ██║ ',
24
+ '██║ ██║███████╗██████╔╝██║ ╚═╝ ██║██║██║ ╚████║███████╗ ╚██████╗╚██████╔╝██║ ╚████║ ██║ ███████╗██╔╝ ██╗ ██║ ',
25
+ '╚═╝ ╚═╝╚══════╝╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ',
26
+ ];
27
+ const STACKED_LINES = [
28
+ '██████╗ ███████╗██████╗ ███╗ ███╗██╗███╗ ██╗███████╗',
29
+ '██╔══██╗██╔════╝██╔══██╗████╗ ████║██║████╗ ██║██╔════╝',
30
+ '██████╔╝█████╗ ██║ ██║██╔████╔██║██║██╔██╗ ██║█████╗ ',
31
+ '██╔══██╗██╔══╝ ██║ ██║██║╚██╔╝██║██║██║╚██╗██║██╔══╝ ',
32
+ '██║ ██║███████╗██████╔╝██║ ╚═╝ ██║██║██║ ╚████║███████╗',
33
+ '╚═╝ ╚═╝╚══════╝╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝',
34
+ ' ██████╗ ██████╗ ███╗ ██╗████████╗███████╗██╗ ██╗████████╗',
35
+ '██╔════╝██╔═══██╗████╗ ██║╚══██╔══╝██╔════╝╚██╗██╔╝╚══██╔══╝',
36
+ '██║ ██║ ██║██╔██╗ ██║ ██║ █████╗ ╚███╔╝ ██║ ',
37
+ '██║ ██║ ██║██║╚██╗██║ ██║ ██╔══╝ ██╔██╗ ██║ ',
38
+ '╚██████╗╚██████╔╝██║ ╚████║ ██║ ███████╗██╔╝ ██╗ ██║ ',
39
+ ' ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ',
40
+ ];
41
+ const ASCII_LINES = [
42
+ ' _ _ _ _ ',
43
+ ' _ _ ___ __| |_ __ (_)_ _ ___ ___ __ ___ _ _| |_ _____ _| |_ ',
44
+ ' | \'_/ -_) _` | \' \\| | \' \\/ -_)___/ _/ _ \\ \' \\ _/ -_) \\ / _|',
45
+ ' |_| \\___\\__,_|_|_|_|_|_||_\\___| \\__\\___/_||_\\__\\___/_\\_\\\\__|',
46
+ ];
47
+ /** Largura mínima (colunas) exigida por cada variante. */
48
+ const MIN_WIDTH = {
49
+ wide: 121,
50
+ stacked: 61,
51
+ ascii: 63,
52
+ };
53
+ /** Linhas de cada variante, na ordem de renderização. */
54
+ const LINES = {
55
+ wide: WIDE_LINES,
56
+ stacked: STACKED_LINES,
57
+ ascii: ASCII_LINES,
58
+ };
59
+ /**
60
+ * Escolhe a variante do banner para o terminal atual.
61
+ *
62
+ * Ordem de preferência, sempre respeitando a largura disponível:
63
+ * `wide` (uma linha, Unicode) → `stacked` (duas linhas, Unicode) → `ascii`
64
+ * (ASCII puro) → `plain` (sem arte; a tela cai no nome em texto).
65
+ *
66
+ * @param width - Colunas disponíveis (ver `useTerminalWidth`).
67
+ * @param unicode - `true` quando o terminal renderiza os blocos do ANSI Shadow.
68
+ * @returns A variante a renderizar.
69
+ * @example
70
+ * selectBanner(140, true) // 'wide'
71
+ * selectBanner(80, true) // 'stacked' (não cabe a wide)
72
+ * selectBanner(80, false) // 'ascii' (sem Unicode)
73
+ */
74
+ export function selectBanner(width, unicode) {
75
+ if (unicode) {
76
+ if (width >= MIN_WIDTH.wide)
77
+ return 'wide';
78
+ if (width >= MIN_WIDTH.stacked)
79
+ return 'stacked';
80
+ }
81
+ if (width >= MIN_WIDTH.ascii)
82
+ return 'ascii';
83
+ return 'plain';
84
+ }
85
+ /**
86
+ * Linhas da variante escolhida.
87
+ *
88
+ * @param variant - Variante de {@link selectBanner}.
89
+ * @returns As linhas da arte; vazio para `plain`.
90
+ */
91
+ export function bannerLines(variant) {
92
+ return variant === 'plain' ? [] : LINES[variant];
93
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Converte a fração de progresso em número de células preenchidas.
3
+ *
4
+ * Exportada para teste: as bordas (0, 1 e valores fora da faixa) são o que mais
5
+ * erra em barra de progresso.
6
+ *
7
+ * @param progress - Fração entre 0 e 1; valores fora da faixa são fixados nela.
8
+ * @param width - Largura total da barra em células.
9
+ * @returns Quantidade de células preenchidas, sempre em `[0, width]`.
10
+ */
11
+ export declare function filledCells(progress: number, width: number): number;
12
+ /**
13
+ * Renderiza uma barra de progresso com percentual.
14
+ *
15
+ * @param props.progress - Fração entre 0 e 1.
16
+ * @param props.color - Cor do preenchimento (token do tema).
17
+ * @param props.trackColor - Cor do trilho vazio (normalmente `theme.muted`).
18
+ * @param props.width - Largura em células. Default: {@link DEFAULT_WIDTH}.
19
+ * @example
20
+ * <Gauge progress={0.4} color={theme.primary} trackColor={theme.muted} />
21
+ */
22
+ export declare function Gauge({ progress, color, trackColor, width, }: {
23
+ progress: number;
24
+ color: string;
25
+ trackColor: string;
26
+ width?: number;
27
+ }): import("react").JSX.Element;
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Barra de progresso (#190 — pacote estético).
4
+ *
5
+ * O `Job` do registro (`../job-registry.tsx`) já carregava `progress`, mas a
6
+ * tela de jobs só mostrava o status textual — a extração de mídia (OCR, ffmpeg,
7
+ * whisper) roda em background e o usuário não tinha noção de quanto falta. Este
8
+ * componente dá essa leitura.
9
+ *
10
+ * Glyphs vêm de `../glyphs.js` (`gaugeFull`/`gaugeEmpty`), então a barra degrada
11
+ * para `#`/`-` no terminal legado do Windows pelo MESMO sinal que já degrada os
12
+ * frames braille do spinner — sem caractere Unicode solto na tela.
13
+ *
14
+ * Cores vêm do tema (varredura `no-hardcoded-colors`): o preenchimento usa a cor
15
+ * recebida e o trilho usa `muted`, o que preserva contraste tanto nas paletas
16
+ * escuras quanto nas claras.
17
+ */
18
+ import { Text } from 'ink';
19
+ import { glyphs } from '../glyphs.js';
20
+ /** Largura default da barra, em células. */
21
+ const DEFAULT_WIDTH = 20;
22
+ /**
23
+ * Converte a fração de progresso em número de células preenchidas.
24
+ *
25
+ * Exportada para teste: as bordas (0, 1 e valores fora da faixa) são o que mais
26
+ * erra em barra de progresso.
27
+ *
28
+ * @param progress - Fração entre 0 e 1; valores fora da faixa são fixados nela.
29
+ * @param width - Largura total da barra em células.
30
+ * @returns Quantidade de células preenchidas, sempre em `[0, width]`.
31
+ */
32
+ export function filledCells(progress, width) {
33
+ if (!Number.isFinite(progress) || progress <= 0)
34
+ return 0;
35
+ if (progress >= 1)
36
+ return width;
37
+ return Math.min(width, Math.max(0, Math.round(progress * width)));
38
+ }
39
+ /**
40
+ * Renderiza uma barra de progresso com percentual.
41
+ *
42
+ * @param props.progress - Fração entre 0 e 1.
43
+ * @param props.color - Cor do preenchimento (token do tema).
44
+ * @param props.trackColor - Cor do trilho vazio (normalmente `theme.muted`).
45
+ * @param props.width - Largura em células. Default: {@link DEFAULT_WIDTH}.
46
+ * @example
47
+ * <Gauge progress={0.4} color={theme.primary} trackColor={theme.muted} />
48
+ */
49
+ export function Gauge({ progress, color, trackColor, width = DEFAULT_WIDTH, }) {
50
+ const filled = filledCells(progress, width);
51
+ const percent = Math.round(Math.min(1, Math.max(0, progress)) * 100);
52
+ return (_jsxs(Text, { children: [_jsx(Text, { color: color, children: glyphs.gaugeFull.repeat(filled) }), _jsx(Text, { color: trackColor, children: glyphs.gaugeEmpty.repeat(width - filled) }), _jsxs(Text, { color: trackColor, children: [" ", String(percent).padStart(3), "%"] })] }));
53
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Renderiza linhas de arte ASCII com gradiente por coluna.
3
+ *
4
+ * @param props.lines - Linhas da arte (mesma fonte, alturas iguais).
5
+ * @param props.colors - Ramp de cores (hex) da paleta ativa; vazia ⇒ sem cor.
6
+ * @returns O banner, ou `null` quando não há linhas (variante `plain`).
7
+ * @example
8
+ * <GradientBanner lines={bannerLines('wide')} colors={theme.gradient ?? []} />
9
+ */
10
+ export declare function GradientBanner({ lines, colors, }: {
11
+ lines: readonly string[];
12
+ colors: readonly string[];
13
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Banner multi-linha com gradiente HORIZONTAL (#190 — pacote estético).
4
+ *
5
+ * Diferença para {@link GradientText}: aquele interpola por CARACTERE ao longo de
6
+ * uma string única — passar uma arte multi-linha a ele faria a ramp escorrer
7
+ * pelas quebras de linha e sair na diagonal, com cada linha começando numa cor
8
+ * diferente. Aqui a ramp é calculada UMA vez pela largura da arte e aplicada por
9
+ * COLUNA: a coluna `x` tem a mesma cor em todas as linhas, que é o que faz o
10
+ * logo ler como um objeto só.
11
+ *
12
+ * Reutiliza `rampColors` de `./gradient-text.js` — a interpolação é a mesma, só
13
+ * muda o eixo. Nenhuma cor literal aqui: a ramp vem do tema (varredura
14
+ * `no-hardcoded-colors`).
15
+ *
16
+ * Contraste: as paradas de cor são as da paleta ativa (claras e escuras já vêm
17
+ * calibradas em `../palettes.ts`), então o banner herda o contraste do tema em
18
+ * vez de impor um seu. Sem `bold` — o atributo derruba o fg truecolor em alguns
19
+ * terminais (ver a nota em `./gradient-text.tsx`).
20
+ */
21
+ import { Box, Text } from 'ink';
22
+ import { rampColors } from './gradient-text.js';
23
+ /**
24
+ * Renderiza linhas de arte ASCII com gradiente por coluna.
25
+ *
26
+ * @param props.lines - Linhas da arte (mesma fonte, alturas iguais).
27
+ * @param props.colors - Ramp de cores (hex) da paleta ativa; vazia ⇒ sem cor.
28
+ * @returns O banner, ou `null` quando não há linhas (variante `plain`).
29
+ * @example
30
+ * <GradientBanner lines={bannerLines('wide')} colors={theme.gradient ?? []} />
31
+ */
32
+ export function GradientBanner({ lines, colors, }) {
33
+ if (lines.length === 0)
34
+ return null;
35
+ const width = Math.max(...lines.map((line) => [...line].length));
36
+ const ramp = rampColors(colors, width);
37
+ return (_jsx(Box, { flexDirection: "column", children: lines.map((line, row) => (_jsx(Text, { children: [...line].map((ch, col) => (
38
+ // Espaço não recebe cor: evita pintar o "fundo" da arte e mantém o
39
+ // banner legível também em paleta clara.
40
+ _jsx(Text, { ...(ch === ' ' || ramp.length === 0 ? {} : { color: ramp[col] }), children: ch }, col))) }, row))) }));
41
+ }
@@ -31,6 +31,10 @@ export interface Glyphs {
31
31
  readonly maskBullet: string;
32
32
  /** Frames do spinner (`components/spinner.tsx`). */
33
33
  readonly spinnerFrames: readonly string[];
34
+ /** Célula PREENCHIDA da barra de progresso (`components/gauge.tsx`). */
35
+ readonly gaugeFull: string;
36
+ /** Célula VAZIA da barra de progresso (`components/gauge.tsx`). */
37
+ readonly gaugeEmpty: string;
34
38
  }
35
39
  /** Glyphs Unicode — terminais modernos (Windows Terminal, iTerm, etc.). */
36
40
  export declare const UNICODE_GLYPHS: Glyphs;
@@ -60,6 +60,8 @@ export const UNICODE_GLYPHS = {
60
60
  arrowDown: '↓',
61
61
  maskBullet: '•',
62
62
  spinnerFrames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
63
+ gaugeFull: '█',
64
+ gaugeEmpty: '░',
63
65
  };
64
66
  /** Fallback ASCII puro — terminal legado do Windows (sem mojibake). */
65
67
  export const ASCII_GLYPHS = {
@@ -70,6 +72,8 @@ export const ASCII_GLYPHS = {
70
72
  arrowDown: 'v',
71
73
  maskBullet: '*',
72
74
  spinnerFrames: ['|', '/', '-', '\\'],
75
+ gaugeFull: '#',
76
+ gaugeEmpty: '-',
73
77
  };
74
78
  /**
75
79
  * Escolhe o conjunto de glyphs conforme o suporte a Unicode.
@@ -1,4 +1,4 @@
1
- import { createHttpClient, getIssue, normalizeIssue, resolveApiKey, type Issue } from '../../../index.js';
1
+ import { createHttpClient, fetchEnumerations, getIssue, normalizeIssue, resolveApiKey, type DetailLookups, type Issue } from '../../../index.js';
2
2
  /**
3
3
  * Estado da busca de detalhe de issue, consumido por
4
4
  * `../screens/issue-detail.tsx`. `no-selection` cobre o caso defensivo de a
@@ -14,6 +14,7 @@ export type IssueDetailState = {
14
14
  } | {
15
15
  status: 'loaded';
16
16
  issue: Issue;
17
+ lookups: DetailLookups;
17
18
  } | {
18
19
  status: 'error-network';
19
20
  message: string;
@@ -39,6 +40,11 @@ export interface UseIssueDetailOptions {
39
40
  getIssue?: typeof getIssue;
40
41
  /** Normaliza o payload bruto no modelo `Issue`; default `normalizeIssue` do core. */
41
42
  normalizeIssue?: typeof normalizeIssue;
43
+ /**
44
+ * Busca as enumerações da instância (`id → nome`); default
45
+ * `fetchEnumerations` do core. Injetável para os testes não tocarem a rede.
46
+ */
47
+ fetchEnumerations?: typeof fetchEnumerations;
42
48
  }
43
49
  /** Valor retornado pelo hook. */
44
50
  export interface UseIssueDetailResult {
@@ -27,7 +27,7 @@
27
27
  * `auth-aborted` (neutro, não um erro), com `retry` disponível.
28
28
  */
29
29
  import { useCallback, useEffect, useState } from 'react';
30
- import { createHttpClient, getIssue, normalizeIssue, resolveApiKey, RedmineForbiddenError, RedmineNotFoundError, } from '../../../index.js';
30
+ import { createHttpClient, collectUsers, fetchEnumerations, getIssue, normalizeIssue, resolveApiKey, RedmineForbiddenError, RedmineNotFoundError, } from '../../../index.js';
31
31
  import { useEnvFallbackAllowed } from '../instance.js';
32
32
  import { ReAuthAbortedError, useAuthGuard } from './use-auth-guard.js';
33
33
  /** Lê `REDMINE_URL` do ambiente, tratando string vazia como ausente (mesma convenção do doctor/home). */
@@ -52,6 +52,7 @@ export function useIssueDetail(issueId, options = {}) {
52
52
  const buildClient = options.createHttpClient ?? createHttpClient;
53
53
  const fetchIssue = options.getIssue ?? getIssue;
54
54
  const normalize = options.normalizeIssue ?? normalizeIssue;
55
+ const loadEnumerations = options.fetchEnumerations ?? fetchEnumerations;
55
56
  // Mandatório (M2-13, #36): ver o JSDoc do módulo.
56
57
  const { guard } = useAuthGuard();
57
58
  // SEGURANÇA (#187): origem config (URL persistida) → sem env-key instance-agnóstica.
@@ -93,7 +94,19 @@ export function useIssueDetail(issueId, options = {}) {
93
94
  const payload = await guard(() => fetchIssue(http, issueId));
94
95
  if (cancelled)
95
96
  return;
96
- setState({ status: 'loaded', issue: normalize(payload) });
97
+ const issue = normalize(payload);
98
+ // Dicionários `id → nome` para o histórico: sem eles a tela mostra
99
+ // `Status: #12 → Atribuída (#7)` — metade ilegível. `fetchEnumerations`
100
+ // é memoizado por instância (uma vez por sessão da TUI) e degrada em
101
+ // silêncio; os usuários saem do próprio payload, sem rede.
102
+ const enums = issue.journals.length > 0 ? await loadEnumerations(http, instanceUrl) : undefined;
103
+ if (cancelled)
104
+ return;
105
+ setState({
106
+ status: 'loaded',
107
+ issue,
108
+ lookups: { ...(enums ?? {}), user: collectUsers(issue) },
109
+ });
97
110
  }
98
111
  catch (cause) {
99
112
  if (cancelled)
@@ -35,17 +35,19 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
35
35
  */
36
36
  import { Box, Text, useInput } from 'ink';
37
37
  import { useEffect, useRef } from 'react';
38
+ import { journalDetailLabel, journalDetailValue } from '../../../index.js';
38
39
  import { attachmentStatusColor, attachmentStatusLabel, deriveAttachmentExtractionStatus, } from '../attachment-status.js';
39
40
  import { Spinner } from '../components/spinner.js';
40
41
  import { ScrollView } from '../components/scroll-view.js';
41
42
  import { glyphs } from '../glyphs.js';
42
43
  import { humanizeFileSize } from '../format-file-size.js';
43
44
  import { useIssueDetail } from '../hooks/use-issue-detail.js';
44
- import { useTerminalHeight } from '../hooks/use-terminal-width.js';
45
+ import { useTerminalHeight, useTerminalWidth } from '../hooks/use-terminal-width.js';
45
46
  import { useNavigation } from '../navigation.js';
46
47
  import { statusColor } from '../status-color.js';
47
48
  import { symbols } from '../symbols.js';
48
49
  import { useTheme } from '../theme.js';
50
+ import { wrapText } from '../wrap.js';
49
51
  import { useHomeSelection } from './home-selection.js';
50
52
  import { useLoadedIssue } from './loaded-issue-context.js';
51
53
  /**
@@ -58,6 +60,10 @@ import { useLoadedIssue } from './loaded-issue-context.js';
58
60
  const CONTENT_OVERHEAD_ROWS = 14;
59
61
  /** Piso da viewport (terminais muito baixos). */
60
62
  const CONTENT_MIN_HEIGHT = 6;
63
+ /** Colunas consumidas fora do texto: moldura da aplicação (2) + padding da tela (2). */
64
+ const CONTENT_WIDTH_OVERHEAD = 4;
65
+ /** Piso da largura de texto (terminais muito estreitos). */
66
+ const CONTENT_MIN_WIDTH = 20;
61
67
  /** Placeholder discreto para campos ausentes (assignee, autor/data de journal, old/new value). ASCII no Windows legado (#84). */
62
68
  const EMPTY_PLACEHOLDER = glyphs.emptyPlaceholder;
63
69
  /** Metadados fixos no topo: id/subject, status/prioridade, autor/responsável, datas. */
@@ -85,14 +91,17 @@ function buildAttachmentRows(issue, theme) {
85
91
  return rows;
86
92
  }
87
93
  /** Constrói as linhas da viewport rolável: bloco de descrição + histórico cronológico de journals. */
88
- function buildContentRows(issue, theme) {
94
+ function buildContentRows(issue, theme, width, lookups) {
89
95
  const rows = [];
90
96
  rows.push(_jsx(Text, { color: theme.primary, children: "Descri\u00E7\u00E3o" }, "desc-header"));
91
97
  if (issue.description === undefined || issue.description === '') {
92
98
  rows.push(_jsx(Text, { color: theme.muted, children: "(sem descri\u00E7\u00E3o)" }, "desc-empty"));
93
99
  }
94
100
  else {
95
- issue.description.split('\n').forEach((line, index) => {
101
+ // Quebra na largura ANTES de virar item: o ScrollView conta itens como
102
+ // linhas de tela, e um parágrafo longo (o caso comum de um chamado) faria o
103
+ // Ink quebrá-lo sozinho em várias, estourando o viewport.
104
+ wrapText(issue.description, width).forEach((line, index) => {
96
105
  rows.push(_jsx(Text, { children: line.length > 0 ? line : ' ' }, `desc-${index}`));
97
106
  });
98
107
  }
@@ -106,13 +115,21 @@ function buildContentRows(issue, theme) {
106
115
  issue.journals.forEach((journal) => {
107
116
  rows.push(_jsxs(Text, { color: theme.muted, children: [journal.user?.name ?? EMPTY_PLACEHOLDER, " ", glyphs.middleDot, " ", journal.created_on] }, `journal-${journal.id}-head`));
108
117
  if (journal.notes !== undefined && journal.notes !== '') {
109
- journal.notes.split('\n').forEach((line, index) => {
118
+ wrapText(journal.notes, width).forEach((line, index) => {
110
119
  rows.push(_jsx(Text, { children: line }, `journal-${journal.id}-note-${index}`));
111
120
  });
112
121
  }
113
- // Details estruturais resumidos: `campo: antigo → novo`, sempre em muted.
122
+ // Details resumidos: `campo: antigo → novo`, sempre em muted. Rótulos e
123
+ // ids vêm da MESMA semântica do bundle (`journalDetailLabel`/`Value` do
124
+ // core) — sem ela a tela mostrava o cru do Redmine (`status_id: 12 → 7`,
125
+ // custom field pelo id). A marca `trusted` das partes não se aplica aqui:
126
+ // ela existe para a fence anti prompt-injection do bundle, e esta tela é
127
+ // interface, não prompt.
114
128
  journal.details.forEach((detail, index) => {
115
- rows.push(_jsxs(Text, { color: theme.muted, children: [' ', detail.name, ": ", detail.old_value ?? EMPTY_PLACEHOLDER, " ", symbols.arrowRight, ' ', detail.new_value ?? EMPTY_PLACEHOLDER] }, `journal-${journal.id}-detail-${index}`));
129
+ const label = journalDetailLabel(detail, issue).text;
130
+ const from = journalDetailValue(detail, detail.old_value, issue, lookups)?.text ?? EMPTY_PLACEHOLDER;
131
+ const to = journalDetailValue(detail, detail.new_value, issue, lookups)?.text ?? EMPTY_PLACEHOLDER;
132
+ rows.push(_jsxs(Text, { color: theme.muted, children: [' ', label, ": ", from, " ", symbols.arrowRight, " ", to] }, `journal-${journal.id}-detail-${index}`));
116
133
  });
117
134
  rows.push(_jsx(Text, { children: " " }, `journal-${journal.id}-spacer`));
118
135
  });
@@ -141,6 +158,10 @@ export function IssueDetailScreen() {
141
158
  // Viewport de descrição CRESCE com o terminal (#190) — só rola quando o conteúdo
142
159
  // passa da tela de verdade, em vez de uma janelinha fixa com espaço vazio embaixo.
143
160
  const contentHeight = Math.max(CONTENT_MIN_HEIGHT, useTerminalHeight() - CONTENT_OVERHEAD_ROWS);
161
+ // Largura útil do texto: o terminal menos a moldura da aplicação (2 colunas)
162
+ // e o padding desta tela (2). Sem descontar, o parágrafo encosta na borda e o
163
+ // Ink o requebra — o mesmo estouro que o wrap existe para evitar.
164
+ const contentWidth = Math.max(CONTENT_MIN_WIDTH, useTerminalWidth() - CONTENT_WIDTH_OVERHEAD);
144
165
  // Espelha a issue carregada no contexto leve consumido por `./export.js`
145
166
  // (#33) — ver o JSDoc do módulo e de `./loaded-issue-context.js` para a
146
167
  // escolha de reuso em memória em vez de refetch.
@@ -175,7 +196,7 @@ export function IssueDetailScreen() {
175
196
  retry();
176
197
  }
177
198
  });
178
- return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.primary, children: "Detalhe da issue" }), state.status === 'no-selection' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "Nenhuma issue selecionada." }) })) : null, state.status === 'loading' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Spinner, {}), " Carregando issue..."] }) })) : null, state.status === 'error-network' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " Falha ao carregar a issue: ", state.message] }) })) : null, state.status === 'error-forbidden' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", state.message] }) })) : null, state.status === 'error-not-found' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", state.message] }) })) : null, state.status === 'auth-aborted' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: state.message }) })) : null, state.status === 'loaded' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(IssueMeta, { issue: state.issue, theme: theme }), _jsx(Box, { marginTop: 1, children: _jsx(ScrollView, { lines: buildContentRows(state.issue, theme), height: contentHeight }) })] })) : null, _jsx(Box, { flexGrow: 1 }), _jsx(Footer, { showRetry: state.status === 'error-network' ||
199
+ return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.primary, children: "Detalhe da issue" }), state.status === 'no-selection' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "Nenhuma issue selecionada." }) })) : null, state.status === 'loading' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Spinner, {}), " Carregando issue..."] }) })) : null, state.status === 'error-network' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " Falha ao carregar a issue: ", state.message] }) })) : null, state.status === 'error-forbidden' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", state.message] }) })) : null, state.status === 'error-not-found' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", state.message] }) })) : null, state.status === 'auth-aborted' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: state.message }) })) : null, state.status === 'loaded' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(IssueMeta, { issue: state.issue, theme: theme }), _jsx(Box, { marginTop: 1, children: _jsx(ScrollView, { lines: buildContentRows(state.issue, theme, contentWidth, state.lookups), height: contentHeight }) })] })) : null, _jsx(Box, { flexGrow: 1 }), _jsx(Footer, { showRetry: state.status === 'error-network' ||
179
200
  state.status === 'error-forbidden' ||
180
201
  state.status === 'error-not-found' ||
181
202
  state.status === 'auth-aborted', showExport: state.status === 'loaded' })] }));
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * Painel de jobs da sessão (#34/M2-11) — empilhado a partir da home
4
4
  * (`./home.tsx`) ou do detalhe (`./issue-detail.tsx`) com a tecla `t`
@@ -40,6 +40,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
40
40
  */
41
41
  import { Box, Text, useInput } from 'ink';
42
42
  import { useRef } from 'react';
43
+ import { Gauge } from '../components/gauge.js';
43
44
  import { Spinner } from '../components/spinner.js';
44
45
  import { glyphs } from '../glyphs.js';
45
46
  import { useListNavigation } from '../hooks/use-list-navigation.js';
@@ -51,7 +52,7 @@ import { useTheme } from '../theme.js';
51
52
  /** Uma linha do painel: ícone/spinner de status, rótulo, badge textual e, quando selecionada, o hint de cancelamento. */
52
53
  function JobRow({ job, selected, theme }) {
53
54
  const icon = jobStatusIcon(job.status);
54
- return (_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: selected ? `${symbols.pointerSmall} ` : ' ' }), job.status === 'processing' ? (_jsx(Spinner, {})) : (_jsx(Text, { color: jobStatusColor(theme, job.status), children: icon })), _jsx(Text, { children: " " }), _jsx(Text, { ...(selected ? { color: theme.primary } : {}), children: job.label }), _jsxs(Text, { color: theme.muted, children: [" [", jobStatusLabel(job.status), "]"] }), selected ? (_jsxs(Text, { color: theme.muted, children: [' ', job.cancelable === true ? '(Ctrl+C cancela)' : '(não cancelável)'] })) : null] }));
55
+ return (_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: selected ? `${symbols.pointerSmall} ` : ' ' }), job.status === 'processing' ? (_jsx(Spinner, {})) : (_jsx(Text, { color: jobStatusColor(theme, job.status), children: icon })), _jsx(Text, { children: " " }), _jsx(Text, { ...(selected ? { color: theme.primary } : {}), children: job.label }), _jsxs(Text, { color: theme.muted, children: [" [", jobStatusLabel(job.status), "]"] }), job.progress !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Gauge, { progress: job.progress, color: jobStatusColor(theme, job.status), trackColor: theme.muted })] })) : null, selected ? (_jsxs(Text, { color: theme.muted, children: [' ', job.cancelable === true ? '(Ctrl+C cancela)' : '(não cancelável)'] })) : null] }));
55
56
  }
56
57
  /**
57
58
  * Tela do painel de jobs: lista as operações da sessão (`../job-registry.tsx`),
@@ -20,7 +20,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
20
20
  import { useCallback, useRef, useState } from 'react';
21
21
  import { Box, Text, useInput } from 'ink';
22
22
  import { resolveApiKey, TOOL_NAME, TOOL_VERSION } from '../../../index.js';
23
+ import { bannerLines, selectBanner } from '../banner.js';
24
+ import { GradientBanner } from '../components/gradient-banner.js';
23
25
  import { GradientText } from '../components/gradient-text.js';
26
+ import { isUnicodeSupported } from '../glyphs.js';
27
+ import { useTerminalWidth } from '../hooks/use-terminal-width.js';
24
28
  import { useEnvFallbackAllowed } from '../instance.js';
25
29
  import { useNavigation } from '../navigation.js';
26
30
  import { useTheme } from '../theme.js';
@@ -37,6 +41,10 @@ function instanceFromEnv(env) {
37
41
  export function WelcomeScreen() {
38
42
  const { navigate } = useNavigation();
39
43
  const theme = useTheme();
44
+ // Banner responsivo: a arte só entra quando cabe na largura atual e o terminal
45
+ // renderiza os blocos Unicode (ver ../banner.ts).
46
+ const variant = selectBanner(useTerminalWidth(), isUnicodeSupported());
47
+ const gradient = theme.gradient ?? [theme.primary];
40
48
  const [checking, setChecking] = useState(false);
41
49
  // Handlers ESTÁVEIS (useCallback+refs) — mesmo padrão do TextInput/app.tsx:
42
50
  // identidade nova por render des/re-subscreve o useInput e perde teclas.
@@ -91,5 +99,5 @@ export function WelcomeScreen() {
91
99
  }
92
100
  }, []);
93
101
  useInput(handleInput);
94
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(GradientText, { colors: theme.gradient ?? [theme.primary], children: TOOL_NAME }), _jsxs(Text, { color: theme.muted, children: ["v", TOOL_VERSION] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "Contexto completo de issues do Redmine, pronto para qualquer LLM." }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.muted, children: ["Pressione ", _jsx(Text, { color: theme.accent, children: "Enter" }), " para continuar,", ' ', _jsx(Text, { color: theme.accent, children: "?" }), " para atalhos,", ' ', _jsx(Text, { color: theme.accent, children: "d" }), " para o doctor,", ' ', _jsx(Text, { color: theme.accent, children: "c" }), " para configura\u00E7\u00E3o,", ' ', _jsx(Text, { color: theme.accent, children: "a" }), " para apar\u00EAncia,", ' ', _jsx(Text, { color: theme.accent, children: "q" }), " para sair."] }) })] }));
102
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [variant === 'plain' ? (_jsx(GradientText, { colors: gradient, children: TOOL_NAME })) : (_jsx(GradientBanner, { lines: bannerLines(variant), colors: gradient })), _jsxs(Text, { color: theme.muted, children: [variant === 'plain' ? '' : `${TOOL_NAME} `, "v", TOOL_VERSION] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "Contexto completo de issues do Redmine, pronto para qualquer LLM." }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.muted, children: ["Pressione ", _jsx(Text, { color: theme.accent, children: "Enter" }), " para continuar,", ' ', _jsx(Text, { color: theme.accent, children: "?" }), " para atalhos,", ' ', _jsx(Text, { color: theme.accent, children: "d" }), " para o doctor,", ' ', _jsx(Text, { color: theme.accent, children: "c" }), " para configura\u00E7\u00E3o,", ' ', _jsx(Text, { color: theme.accent, children: "a" }), " para apar\u00EAncia,", ' ', _jsx(Text, { color: theme.accent, children: "q" }), " para sair."] }) })] }));
95
103
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Quebra de linha por LARGURA para as viewports roláveis da TUI.
3
+ *
4
+ * `../components/scroll-view.tsx` mostra `height` ITENS do array `lines` e
5
+ * assume que cada item ocupa UMA linha na tela. Texto vindo do Redmine não
6
+ * respeita isso: a descrição de um chamado costuma ter parágrafos de centenas
7
+ * de caracteres, o Ink os quebra sozinho em várias linhas visuais, e a conta do
8
+ * viewport passa a errar — o conteúdo escapa da janela, o começo das frases
9
+ * some e a moldura da aplicação aparece em pedaços no meio do texto.
10
+ *
11
+ * Quebrando ANTES de montar as linhas, cada item volta a ser uma linha visual e
12
+ * o ScrollView fica correto sem precisar saber medir texto.
13
+ *
14
+ * A medida é em code points (`[...text]`), a mesma convenção de `./truncate.ts`:
15
+ * suficiente para o conteúdo latino dos chamados e sem trazer dependência de
16
+ * largura de glifo (CJK/emoji contariam 2 colunas).
17
+ */
18
+ /**
19
+ * Quebra um parágrafo em linhas de no máximo `width` colunas, sem cortar
20
+ * palavras (exceto quando a palavra sozinha excede a largura).
21
+ *
22
+ * @param text - Uma linha lógica (sem `\n`).
23
+ * @param width - Largura disponível em colunas; `<= 0` devolve o texto inteiro
24
+ * numa linha só (não há o que quebrar de forma útil).
25
+ * @returns As linhas resultantes; nunca vazio — texto vazio devolve `['']` para
26
+ * preservar a linha em branco do original.
27
+ * @example
28
+ * wrapLine('uma frase bem comprida', 10); // ['uma frase', 'bem', 'comprida']
29
+ */
30
+ export declare function wrapLine(text: string, width: number): string[];
31
+ /**
32
+ * Quebra um texto multi-linha, preservando as linhas em branco do original.
33
+ *
34
+ * @param text - Texto com `\n` (descrição de issue, nota de journal).
35
+ * @param width - Largura disponível em colunas.
36
+ * @returns Todas as linhas já ajustadas à largura.
37
+ */
38
+ export declare function wrapText(text: string, width: number): string[];
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Quebra de linha por LARGURA para as viewports roláveis da TUI.
3
+ *
4
+ * `../components/scroll-view.tsx` mostra `height` ITENS do array `lines` e
5
+ * assume que cada item ocupa UMA linha na tela. Texto vindo do Redmine não
6
+ * respeita isso: a descrição de um chamado costuma ter parágrafos de centenas
7
+ * de caracteres, o Ink os quebra sozinho em várias linhas visuais, e a conta do
8
+ * viewport passa a errar — o conteúdo escapa da janela, o começo das frases
9
+ * some e a moldura da aplicação aparece em pedaços no meio do texto.
10
+ *
11
+ * Quebrando ANTES de montar as linhas, cada item volta a ser uma linha visual e
12
+ * o ScrollView fica correto sem precisar saber medir texto.
13
+ *
14
+ * A medida é em code points (`[...text]`), a mesma convenção de `./truncate.ts`:
15
+ * suficiente para o conteúdo latino dos chamados e sem trazer dependência de
16
+ * largura de glifo (CJK/emoji contariam 2 colunas).
17
+ */
18
+ /**
19
+ * Quebra um parágrafo em linhas de no máximo `width` colunas, sem cortar
20
+ * palavras (exceto quando a palavra sozinha excede a largura).
21
+ *
22
+ * @param text - Uma linha lógica (sem `\n`).
23
+ * @param width - Largura disponível em colunas; `<= 0` devolve o texto inteiro
24
+ * numa linha só (não há o que quebrar de forma útil).
25
+ * @returns As linhas resultantes; nunca vazio — texto vazio devolve `['']` para
26
+ * preservar a linha em branco do original.
27
+ * @example
28
+ * wrapLine('uma frase bem comprida', 10); // ['uma frase', 'bem', 'comprida']
29
+ */
30
+ export function wrapLine(text, width) {
31
+ if (width <= 0)
32
+ return [text];
33
+ const chars = [...text];
34
+ if (chars.length <= width)
35
+ return [text];
36
+ const lines = [];
37
+ let current = '';
38
+ const pushCurrent = () => {
39
+ if (current.length > 0) {
40
+ lines.push(current);
41
+ current = '';
42
+ }
43
+ };
44
+ for (const word of text.split(' ')) {
45
+ const wordLength = [...word].length;
46
+ const currentLength = [...current].length;
47
+ // Palavra maior que a largura (URL, hash, caminho): quebra à força, em
48
+ // pedaços do tamanho da linha — melhor que estourar o viewport.
49
+ if (wordLength > width) {
50
+ pushCurrent();
51
+ let rest = [...word];
52
+ while (rest.length > width) {
53
+ lines.push(rest.slice(0, width).join(''));
54
+ rest = rest.slice(width);
55
+ }
56
+ current = rest.join('');
57
+ continue;
58
+ }
59
+ if (currentLength === 0) {
60
+ current = word;
61
+ }
62
+ else if (currentLength + 1 + wordLength <= width) {
63
+ current = `${current} ${word}`;
64
+ }
65
+ else {
66
+ pushCurrent();
67
+ current = word;
68
+ }
69
+ }
70
+ pushCurrent();
71
+ return lines.length > 0 ? lines : [''];
72
+ }
73
+ /**
74
+ * Quebra um texto multi-linha, preservando as linhas em branco do original.
75
+ *
76
+ * @param text - Texto com `\n` (descrição de issue, nota de journal).
77
+ * @param width - Largura disponível em colunas.
78
+ * @returns Todas as linhas já ajustadas à largura.
79
+ */
80
+ export function wrapText(text, width) {
81
+ return text.split('\n').flatMap((line) => wrapLine(line, width));
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redmine-context",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Consumidor de Redmine que entrega contexto completo de issues (texto + mídia extraída localmente) para qualquer LLM",
5
5
  "type": "module",
6
6
  "license": "MIT",