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.
- package/README.md +20 -5
- package/dist/bundle/journal-detail.d.ts +89 -0
- package/dist/bundle/journal-detail.js +260 -0
- package/dist/bundle/markdown.d.ts +7 -0
- package/dist/bundle/markdown.js +21 -12
- package/dist/client/enumerations.d.ts +39 -0
- package/dist/client/enumerations.js +76 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/fetch-issue-bundle.js +17 -2
- package/dist/fetch-issue-search.d.ts +10 -0
- package/dist/fetch-issue-search.js +1 -1
- package/dist/fetch-last-issues.d.ts +96 -0
- package/dist/fetch-last-issues.js +120 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +22 -2
- package/dist/normalize/issue.js +32 -3
- package/dist/surfaces/cli/commands.d.ts +17 -0
- package/dist/surfaces/cli/commands.js +105 -12
- package/dist/surfaces/cli/main.js +22 -3
- package/dist/surfaces/mcp/server.d.ts +22 -45
- package/dist/surfaces/mcp/server.js +67 -56
- package/dist/surfaces/mcp/tools.d.ts +109 -0
- package/dist/surfaces/mcp/tools.js +93 -0
- package/dist/surfaces/tui/app.d.ts +14 -0
- package/dist/surfaces/tui/app.js +40 -3
- package/dist/surfaces/tui/banner.d.ts +43 -0
- package/dist/surfaces/tui/banner.js +93 -0
- package/dist/surfaces/tui/components/gauge.d.ts +27 -0
- package/dist/surfaces/tui/components/gauge.js +53 -0
- package/dist/surfaces/tui/components/gradient-banner.d.ts +13 -0
- package/dist/surfaces/tui/components/gradient-banner.js +41 -0
- package/dist/surfaces/tui/components/text-input.js +23 -4
- package/dist/surfaces/tui/glyphs.d.ts +8 -0
- package/dist/surfaces/tui/glyphs.js +8 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +7 -1
- package/dist/surfaces/tui/hooks/use-issue-detail.js +15 -2
- package/dist/surfaces/tui/hooks/use-issue-search.d.ts +25 -2
- package/dist/surfaces/tui/hooks/use-issue-search.js +14 -2
- package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +8 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.js +12 -1
- package/dist/surfaces/tui/hooks/use-my-issues.d.ts +9 -0
- package/dist/surfaces/tui/hooks/use-my-issues.js +6 -2
- package/dist/surfaces/tui/hooks/use-status-options.d.ts +38 -0
- package/dist/surfaces/tui/hooks/use-status-options.js +86 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +16 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.js +15 -0
- package/dist/surfaces/tui/hooks/use-typing-guard.d.ts +19 -0
- package/dist/surfaces/tui/hooks/use-typing-guard.js +58 -0
- package/dist/surfaces/tui/list-window.d.ts +39 -0
- package/dist/surfaces/tui/list-window.js +41 -0
- package/dist/surfaces/tui/screens/export.js +6 -2
- package/dist/surfaces/tui/screens/home.js +161 -33
- package/dist/surfaces/tui/screens/issue-detail.js +55 -16
- package/dist/surfaces/tui/screens/jobs.js +3 -2
- package/dist/surfaces/tui/screens/welcome.js +9 -1
- package/dist/surfaces/tui/status-color.d.ts +21 -0
- package/dist/surfaces/tui/status-color.js +33 -0
- package/dist/surfaces/tui/wrap.d.ts +38 -0
- package/dist/surfaces/tui/wrap.js +82 -0
- 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
|
|
53
|
-
* rodapé
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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