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
@@ -35,29 +35,47 @@ 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 { truncate } from '../truncate.js';
51
+ import { wrapText } from '../wrap.js';
49
52
  import { useHomeSelection } from './home-selection.js';
50
53
  import { useLoadedIssue } from './loaded-issue-context.js';
51
54
  /**
52
- * Overhead de linhas FORA da viewport de descrição (breadcrumb + título + meta +
53
- * rodapé + paddings). A viewport cresce com o terminal: `altura - OVERHEAD`
54
- * (#190). Com a altura default dos testes (24) dá 10 — o valor histórico — então
55
- * os snapshots/asserções de rolagem seguem iguais; em terminal real a descrição
56
- * usa quase toda a tela e rola quando passa DE VERDADE.
55
+ * Overhead de linhas FORA da viewport de descrição: breadcrumb, título, meta,
56
+ * rodapé e paddings DESTA tela.
57
+ *
58
+ * A moldura da aplicação não entra na conta: o shell já entrega a altura sem
59
+ * ela (`TerminalHeightProvider` em `../app.tsx`). Contar a menos faz o viewport
60
+ * pedir mais linhas do que cabem — o conteúdo transborda e o Ink, que redesenha
61
+ * por diff, sobrescreve linhas já impressas.
57
62
  */
58
63
  const CONTENT_OVERHEAD_ROWS = 14;
59
64
  /** Piso da viewport (terminais muito baixos). */
60
65
  const CONTENT_MIN_HEIGHT = 6;
66
+ /**
67
+ * Colunas consumidas fora do texto: moldura da aplicação (2), padding da tela
68
+ * (2) e uma folga de 2.
69
+ *
70
+ * A folga existe porque errar para MENOS é pior que perder duas colunas: uma
71
+ * linha larga demais seria truncada com `…` bem no fim (ou, sem o truncate,
72
+ * quebrada pelo Ink, furando a conta do viewport).
73
+ */
74
+ const CONTENT_WIDTH_OVERHEAD = 6;
75
+ /** Piso da largura de texto (terminais muito estreitos). */
76
+ const CONTENT_MIN_WIDTH = 20;
77
+ /** Corte de cada lado de uma alteração de journal (ver `buildContentRows`). */
78
+ const DETAIL_VALUE_WIDTH = 40;
61
79
  /** Placeholder discreto para campos ausentes (assignee, autor/data de journal, old/new value). ASCII no Windows legado (#84). */
62
80
  const EMPTY_PLACEHOLDER = glyphs.emptyPlaceholder;
63
81
  /** Metadados fixos no topo: id/subject, status/prioridade, autor/responsável, datas. */
@@ -67,7 +85,7 @@ function IssueMeta({ issue, theme }) {
67
85
  /** Constrói a linha de um único anexo: nome (texto derivado, puro) · tamanho humanizado · content_type · badge de status. */
68
86
  function buildAttachmentRow(attachment, theme) {
69
87
  const status = deriveAttachmentExtractionStatus(attachment);
70
- return (_jsxs(Text, { children: [attachment.filename, ' ', _jsxs(Text, { color: theme.muted, children: ["(", humanizeFileSize(attachment.filesize), " ", glyphs.middleDot, " ", attachment.content_type ?? EMPTY_PLACEHOLDER, ")"] }), ' ', _jsxs(Text, { color: attachmentStatusColor(theme, status), children: ["[", attachmentStatusLabel(status), "]"] })] }, `attachment-${attachment.id}`));
88
+ return (_jsxs(Text, { wrap: "truncate", children: [attachment.filename, ' ', _jsxs(Text, { color: theme.muted, children: ["(", humanizeFileSize(attachment.filesize), " ", glyphs.middleDot, " ", attachment.content_type ?? EMPTY_PLACEHOLDER, ")"] }), ' ', _jsxs(Text, { color: attachmentStatusColor(theme, status), children: ["[", attachmentStatusLabel(status), "]"] })] }, `attachment-${attachment.id}`));
71
89
  }
72
90
  /** Constrói as linhas da seção de anexos (após os journals): cabeçalho + 1 linha por anexo, ou "(nenhum)". */
73
91
  function buildAttachmentRows(issue, theme) {
@@ -85,15 +103,18 @@ function buildAttachmentRows(issue, theme) {
85
103
  return rows;
86
104
  }
87
105
  /** Constrói as linhas da viewport rolável: bloco de descrição + histórico cronológico de journals. */
88
- function buildContentRows(issue, theme) {
106
+ function buildContentRows(issue, theme, width, lookups) {
89
107
  const rows = [];
90
108
  rows.push(_jsx(Text, { color: theme.primary, children: "Descri\u00E7\u00E3o" }, "desc-header"));
91
109
  if (issue.description === undefined || issue.description === '') {
92
110
  rows.push(_jsx(Text, { color: theme.muted, children: "(sem descri\u00E7\u00E3o)" }, "desc-empty"));
93
111
  }
94
112
  else {
95
- issue.description.split('\n').forEach((line, index) => {
96
- rows.push(_jsx(Text, { children: line.length > 0 ? line : ' ' }, `desc-${index}`));
113
+ // Quebra na largura ANTES de virar item: o ScrollView conta itens como
114
+ // linhas de tela, e um parágrafo longo (o caso comum de um chamado) faria o
115
+ // Ink quebrá-lo sozinho em várias, estourando o viewport.
116
+ wrapText(issue.description, width).forEach((line, index) => {
117
+ rows.push(_jsx(Text, { wrap: "truncate", children: line.length > 0 ? line : ' ' }, `desc-${index}`));
97
118
  });
98
119
  }
99
120
  rows.push(_jsx(Text, { children: " " }, "desc-spacer"));
@@ -104,15 +125,29 @@ function buildContentRows(issue, theme) {
104
125
  else {
105
126
  // Cronológico: preserva a ordem já entregue por `getIssue`/`normalizeIssue`.
106
127
  issue.journals.forEach((journal) => {
107
- rows.push(_jsxs(Text, { color: theme.muted, children: [journal.user?.name ?? EMPTY_PLACEHOLDER, " ", glyphs.middleDot, " ", journal.created_on] }, `journal-${journal.id}-head`));
128
+ rows.push(_jsxs(Text, { color: theme.muted, wrap: "truncate", children: [journal.user?.name ?? EMPTY_PLACEHOLDER, " ", glyphs.middleDot, " ", journal.created_on] }, `journal-${journal.id}-head`));
108
129
  if (journal.notes !== undefined && journal.notes !== '') {
109
- journal.notes.split('\n').forEach((line, index) => {
110
- rows.push(_jsx(Text, { children: line }, `journal-${journal.id}-note-${index}`));
130
+ wrapText(journal.notes, width).forEach((line, index) => {
131
+ rows.push(_jsx(Text, { wrap: "truncate", children: line }, `journal-${journal.id}-note-${index}`));
111
132
  });
112
133
  }
113
- // Details estruturais resumidos: `campo: antigo → novo`, sempre em muted.
134
+ // Details resumidos: `campo: antigo → novo`, sempre em muted. Rótulos e
135
+ // ids vêm da MESMA semântica do bundle (`journalDetailLabel`/`Value` do
136
+ // core) — sem ela a tela mostrava o cru do Redmine (`status_id: 12 → 7`,
137
+ // custom field pelo id). A marca `trusted` das partes não se aplica aqui:
138
+ // ela existe para a fence anti prompt-injection do bundle, e esta tela é
139
+ // interface, não prompt.
114
140
  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}`));
141
+ const label = journalDetailLabel(detail, issue).text;
142
+ // Valores de campo TEXTO (descrição/assunto) trazem o conteúdo inteiro:
143
+ // editar a descrição guarda os dois textos completos na alteração. No
144
+ // histórico interessa O QUE mudou, não o texto todo — o valor completo
145
+ // está no bundle, para o LLM. Sem o corte, uma linha de centenas de
146
+ // caracteres fura a conta do viewport (1 item = 1 linha de tela).
147
+ const short = (part) => truncate(part, DETAIL_VALUE_WIDTH);
148
+ const from = short(journalDetailValue(detail, detail.old_value, issue, lookups)?.text ?? EMPTY_PLACEHOLDER);
149
+ const to = short(journalDetailValue(detail, detail.new_value, issue, lookups)?.text ?? EMPTY_PLACEHOLDER);
150
+ rows.push(_jsxs(Text, { color: theme.muted, wrap: "truncate", children: [' ', label, ": ", from, " ", symbols.arrowRight, " ", to] }, `journal-${journal.id}-detail-${index}`));
116
151
  });
117
152
  rows.push(_jsx(Text, { children: " " }, `journal-${journal.id}-spacer`));
118
153
  });
@@ -141,6 +176,10 @@ export function IssueDetailScreen() {
141
176
  // Viewport de descrição CRESCE com o terminal (#190) — só rola quando o conteúdo
142
177
  // passa da tela de verdade, em vez de uma janelinha fixa com espaço vazio embaixo.
143
178
  const contentHeight = Math.max(CONTENT_MIN_HEIGHT, useTerminalHeight() - CONTENT_OVERHEAD_ROWS);
179
+ // Largura útil do texto: o terminal menos a moldura da aplicação (2 colunas)
180
+ // e o padding desta tela (2). Sem descontar, o parágrafo encosta na borda e o
181
+ // Ink o requebra — o mesmo estouro que o wrap existe para evitar.
182
+ const contentWidth = Math.max(CONTENT_MIN_WIDTH, useTerminalWidth() - CONTENT_WIDTH_OVERHEAD);
144
183
  // Espelha a issue carregada no contexto leve consumido por `./export.js`
145
184
  // (#33) — ver o JSDoc do módulo e de `./loaded-issue-context.js` para a
146
185
  // escolha de reuso em memória em vez de refetch.
@@ -175,7 +214,7 @@ export function IssueDetailScreen() {
175
214
  retry();
176
215
  }
177
216
  });
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' ||
217
+ 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
218
  state.status === 'error-forbidden' ||
180
219
  state.status === 'error-not-found' ||
181
220
  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
  }
@@ -19,3 +19,24 @@ import type { Theme } from './theme.js';
19
19
  * statusColor(theme, 'Fechado') // theme.success
20
20
  */
21
21
  export declare function statusColor(theme: Theme, statusName: string): string;
22
+ /**
23
+ * Cor do badge do FILTRO rápido de status (`f` na home).
24
+ *
25
+ * Coerente com {@link statusColor}, que já pinta "fechado" de `success` e usa
26
+ * `primary` para o estado ativo — assim o badge do filtro e o badge de cada
27
+ * issue na lista não contam histórias diferentes sobre a mesma palavra.
28
+ *
29
+ * `all` fica em `muted` de propósito: é a ausência de filtro, e um badge
30
+ * chamativo aí competiria com o conteúdo. Qualquer estado FILTRADO puxa cor,
31
+ * que é o sinal de "tem filtro ligado".
32
+ *
33
+ * Contraste: todos são tokens do tema, calibrados por paleta (as claras usam
34
+ * cores saturadas, as escuras cores claras) — ver `./palettes.ts`.
35
+ *
36
+ * @param theme - Tema ativo.
37
+ * @param filter - Filtro corrente (agregado ou id de status da instância).
38
+ * @param label - Nome do status, quando o filtro é um id — permite casar a cor
39
+ * com a que a lista usa para aquele mesmo status.
40
+ * @returns O token de cor do tema para o badge.
41
+ */
42
+ export declare function statusFilterColor(theme: Theme, filter: 'open' | 'closed' | 'all' | number, label?: string): string;
@@ -21,3 +21,36 @@ export function statusColor(theme, statusName) {
21
21
  }
22
22
  return theme.primary;
23
23
  }
24
+ /**
25
+ * Cor do badge do FILTRO rápido de status (`f` na home).
26
+ *
27
+ * Coerente com {@link statusColor}, que já pinta "fechado" de `success` e usa
28
+ * `primary` para o estado ativo — assim o badge do filtro e o badge de cada
29
+ * issue na lista não contam histórias diferentes sobre a mesma palavra.
30
+ *
31
+ * `all` fica em `muted` de propósito: é a ausência de filtro, e um badge
32
+ * chamativo aí competiria com o conteúdo. Qualquer estado FILTRADO puxa cor,
33
+ * que é o sinal de "tem filtro ligado".
34
+ *
35
+ * Contraste: todos são tokens do tema, calibrados por paleta (as claras usam
36
+ * cores saturadas, as escuras cores claras) — ver `./palettes.ts`.
37
+ *
38
+ * @param theme - Tema ativo.
39
+ * @param filter - Filtro corrente (agregado ou id de status da instância).
40
+ * @param label - Nome do status, quando o filtro é um id — permite casar a cor
41
+ * com a que a lista usa para aquele mesmo status.
42
+ * @returns O token de cor do tema para o badge.
43
+ */
44
+ export function statusFilterColor(theme, filter, label) {
45
+ // Status ESPECÍFICO da instância: reusa a heurística por nome, para o badge
46
+ // do filtro e o badge de cada issue da lista combinarem ("Em Andamento" é
47
+ // warning nos dois lugares).
48
+ if (typeof filter === 'number') {
49
+ return label === undefined ? theme.primary : statusColor(theme, label);
50
+ }
51
+ if (filter === 'open')
52
+ return theme.primary;
53
+ if (filter === 'closed')
54
+ return theme.success;
55
+ return theme.muted;
56
+ }
@@ -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.2.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",