redmine-context 1.1.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.
@@ -113,6 +113,23 @@ const RELATION_TYPES = new Set([
113
113
  function idToken(raw) {
114
114
  return /^\d+$/.test(raw) ? raw : undefined;
115
115
  }
116
+ /**
117
+ * Achata um valor de alteração em UMA linha.
118
+ *
119
+ * Editar a descrição de uma issue guarda o texto INTEIRO — com quebras de linha
120
+ * — nos dois lados do detail. Um resumo de alteração é, por definição, de uma
121
+ * linha: no bundle o valor multi-linha quebra o formato `- campo: antes → depois`
122
+ * (e escapa visualmente da fence); na TUI vira várias linhas de tela, furando a
123
+ * conta do viewport, que assume um item por linha.
124
+ *
125
+ * O conteúdo completo continua disponível na seção Descrição do bundle.
126
+ *
127
+ * @param raw - Valor bruto do detail.
128
+ * @returns O mesmo texto com quebras e espaços repetidos colapsados.
129
+ */
130
+ function flatten(raw) {
131
+ return raw.replace(/\s*\r?\n\s*/g, ' ').trim();
132
+ }
116
133
  /**
117
134
  * Ref ATUAL da issue correspondente a um atributo, quando o contrato a carrega.
118
135
  *
@@ -206,18 +223,21 @@ export function journalDetailLabel(detail, issue) {
206
223
  export function journalDetailValue(detail, raw, issue, lookups) {
207
224
  if (raw === null || raw === undefined || raw === '')
208
225
  return undefined;
209
- const id = idToken(raw);
226
+ const flat = flatten(raw);
227
+ if (flat === '')
228
+ return undefined;
229
+ const id = idToken(flat);
210
230
  // Um detail `relation` registra a issue do outro lado da relação.
211
231
  if (detail.property === 'relation') {
212
- return id === undefined ? { text: raw, trusted: false } : { text: `issue #${id}`, trusted: true };
232
+ return id === undefined ? { text: flat, trusted: false } : { text: `issue #${id}`, trusted: true };
213
233
  }
214
234
  if (detail.property !== 'attr')
215
- return { text: raw, trusted: false };
235
+ return { text: flat, trusted: false };
216
236
  if (detail.name === 'done_ratio') {
217
- return id === undefined ? { text: raw, trusted: false } : { text: `${id}%`, trusted: true };
237
+ return id === undefined ? { text: flat, trusted: false } : { text: `${id}%`, trusted: true };
218
238
  }
219
239
  if (ISSUE_REF_ATTRS.has(detail.name)) {
220
- return id === undefined ? { text: raw, trusted: false } : { text: `issue #${id}`, trusted: true };
240
+ return id === undefined ? { text: flat, trusted: false } : { text: `issue #${id}`, trusted: true };
221
241
  }
222
242
  // Dicionário da instância primeiro: resolve QUALQUER id, inclusive o valor
223
243
  // histórico que não corresponde mais ao estado atual.
@@ -236,5 +256,5 @@ export function journalDetailValue(detail, raw, issue, lookups) {
236
256
  if (id !== undefined && ATTR_LABELS[detail.name] !== undefined && detail.name.endsWith('_id')) {
237
257
  return { text: `#${id}`, trusted: true };
238
258
  }
239
- return { text: raw, trusted: false };
259
+ return { text: flat, trusted: false };
240
260
  }
@@ -16,6 +16,7 @@
16
16
  * Mantém a fronteira do ADR-005: encapsula os módulos internos (`client`,
17
17
  * `bundle`) para que a superfície permaneça fina e sem acesso a URL/host.
18
18
  */
19
+ import { type SearchListItem } from './bundle/index.js';
19
20
  /** Limite default de resultados quando a superfície não informa `limit`. */
20
21
  export declare const SEARCH_DEFAULT_LIMIT = 25;
21
22
  /**
@@ -51,6 +52,15 @@ export interface FetchIssueSearchOptions {
51
52
  export interface IssueSearchResult {
52
53
  /** Lista compacta em Markdown pronta para o CallToolResult. */
53
54
  content: string;
55
+ /**
56
+ * Os mesmos itens em forma ESTRUTURADA.
57
+ *
58
+ * O `content` é Markdown com fences `<untrusted-content>` — marcação
59
+ * anti prompt-injection destinada ao LLM. Uma interface que o exiba mostra
60
+ * essas tags ao usuário, que é ruído: a TUI renderiza a partir daqui e aplica
61
+ * sua própria apresentação.
62
+ */
63
+ items: readonly SearchListItem[];
54
64
  /** Número de itens retornados. */
55
65
  count: number;
56
66
  /** Avisos de degradação (ex.: `/search` indisponível). Vazio no caminho feliz. */
@@ -116,5 +116,5 @@ export async function fetchIssueSearch(options) {
116
116
  }
117
117
  const items = payloads.slice(0, limit).map(toSearchListItem);
118
118
  const content = buildSearchListMarkdown(items, { query, warnings });
119
- return { content, count: items.length, warnings, degraded };
119
+ return { content, items, count: items.length, warnings, degraded };
120
120
  }
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { extractIssueAttachments, type ExtractIssueAttachmentsOptions, } from '.
7
7
  export { fetchAttachmentText, fetchAttachmentTextCacheFirst, AttachmentNotFoundError, type FetchAttachmentTextOptions, type FetchAttachmentTextCacheFirstOptions, type AttachmentTextResult, } from './fetch-attachment-text.js';
8
8
  export { extractIssueAttachmentsCacheFirst, makeQueueBackgroundExtractor, processingResult, type BackgroundExtractionTarget, type BackgroundExtractor, type BackgroundCompute, type CacheFirstExtractionOptions, type QueueBackgroundOptions, } from './cache-first.js';
9
9
  export { fetchIssueSearch, SEARCH_DEFAULT_LIMIT, type FetchIssueSearchOptions, type IssueSearchFilters, type IssueSearchResult, } from './fetch-issue-search.js';
10
+ export type { SearchListItem } from './bundle/index.js';
10
11
  export { searchIssues, type SearchIssuesOptions, type SearchIssuesPage } from './client/index.js';
11
12
  export { fetchLastIssues, LAST_DEFAULT_ORDER, LAST_DEFAULT_COUNT, LAST_MAX_COUNT, type LastIssuesOrder, type FetchLastIssuesOptions, type LastIssuesResult, } from './fetch-last-issues.js';
12
13
  export { createHttpClient, type HttpClient, type HttpClientOptions, type QueryParams, } from './client/index.js';
@@ -35,12 +35,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
35
35
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
36
36
  import { Breadcrumb } from './components/breadcrumb.js';
37
37
  import { isUnicodeSupported } from './glyphs.js';
38
- import { TerminalSizeProvider, useTerminalHeight } from './hooks/use-terminal-width.js';
38
+ import { TerminalHeightProvider, TerminalSizeProvider, useTerminalHeight, } from './hooks/use-terminal-width.js';
39
39
  import { applyTerminalColors } from './terminal-colors.js';
40
40
  import { ReAuthAbortedError } from './hooks/use-auth-guard.js';
41
41
  import { consumeEscapeInterceptor } from './hooks/use-escape-interceptor.js';
42
42
  import { useExitGuard } from './hooks/use-exit-guard.js';
43
43
  import { useOnboardingCallbacks } from './hooks/use-onboarding-callbacks.js';
44
+ import { isTyping } from './hooks/use-typing-guard.js';
44
45
  import { JobRegistryProvider } from './job-registry.js';
45
46
  import { NavigationProvider, useNavigation, useNavigationStack } from './navigation.js';
46
47
  import { HomeSelectionProvider } from './screens/home-selection.js';
@@ -75,6 +76,8 @@ export function borderStyleFor(unicode) {
75
76
  }
76
77
  /** Estilo resolvido para o ambiente atual — decidido uma vez, no import. */
77
78
  const BORDER_STYLE = borderStyleFor(isUnicodeSupported());
79
+ /** Linhas consumidas pela moldura (topo + base). */
80
+ const BORDER_ROWS = 2;
78
81
  /**
79
82
  * Decide a ação de `Esc`: abandono do re-auth (fix do review #119) quando a
80
83
  * tela atual é do fluxo de onboarding (`onboarding-*`) E há um `reAuth` em
@@ -165,7 +168,10 @@ function AppShell() {
165
168
  const abortReAuthRef = useRef(abortReAuth);
166
169
  abortReAuthRef.current = abortReAuth;
167
170
  const handleGlobalInput = useCallback((input, key) => {
168
- if (input === 'q') {
171
+ // `q` sai FORA de campo de texto: o Ink entrega a tecla a todos os
172
+ // handlers, então sem esta guarda digitar uma URL com "q" (ou uma senha)
173
+ // fecharia a TUI no meio do onboarding (ver ./hooks/use-typing-guard.ts).
174
+ if (input === 'q' && !isTyping()) {
169
175
  exitRef.current();
170
176
  return;
171
177
  }
@@ -207,5 +213,5 @@ function AppShell() {
207
213
  // não nas telas, então nenhuma tela precisa saber que existe uma. As duas
208
214
  // linhas da borda saem do minHeight para o conteúdo não estourar a altura do
209
215
  // terminal (o que empurraria o topo para fora no modo full-screen).
210
- _jsxs(Box, { flexDirection: "column", minHeight: Math.max(1, rows - 2), borderStyle: BORDER_STYLE, borderColor: theme.border, children: [_jsx(Breadcrumb, { stack: stack }), armed ? (_jsx(Box, { paddingX: 1, marginBottom: 1, children: _jsxs(Text, { color: theme.warning, children: [symbols.warning, " Pressione Ctrl+C de novo para sair."] }) })) : null, _jsx(Box, { flexGrow: 1, flexDirection: "column", children: _jsx(Screen, {}) })] }));
216
+ _jsxs(Box, { flexDirection: "column", minHeight: Math.max(1, rows - BORDER_ROWS), borderStyle: BORDER_STYLE, borderColor: theme.border, children: [_jsx(Breadcrumb, { stack: stack }), armed ? (_jsx(Box, { paddingX: 1, marginBottom: 1, children: _jsxs(Text, { color: theme.warning, children: [symbols.warning, " Pressione Ctrl+C de novo para sair."] }) })) : null, _jsx(Box, { flexGrow: 1, flexDirection: "column", children: _jsx(TerminalHeightProvider, { height: Math.max(1, rows - BORDER_ROWS), children: _jsx(Screen, {}) }) })] }));
211
217
  }
@@ -30,6 +30,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
30
30
  */
31
31
  import { useCallback, useRef } from 'react';
32
32
  import { Text, useInput } from 'ink';
33
+ import { useTypingGuard } from '../hooks/use-typing-guard.js';
33
34
  import { useTheme } from '../theme.js';
34
35
  import { truncate, truncateStart } from '../truncate.js';
35
36
  /**
@@ -79,21 +80,39 @@ export function TextInput({ value, onChange, onSubmit, mask, placeholder, isActi
79
80
  return;
80
81
  }
81
82
  if (key.backspace || key.delete) {
82
- onChangeRef.current(valueRef.current.slice(0, -1));
83
+ // A ref avança JUNTO com a emissão: sincronizá-la só no render (abaixo)
84
+ // faz duas teclas chegadas antes do commit do React partirem do mesmo
85
+ // valor velho — a última vencia e as anteriores se perdiam. Digitar uma
86
+ // URL em velocidade normal corrompia o campo.
87
+ const next = valueRef.current.slice(0, -1);
88
+ valueRef.current = next;
89
+ onChangeRef.current(next);
83
90
  return;
84
91
  }
85
- // Reason: tecla ÚNICA reservada por um controle da tela pai (ex.: "f"
86
- // cicla o filtro de status na busca da home, M2-07/#30) — ignorada
92
+ // Reason: tecla ÚNICA reservada por um controle da tela pai ignorada
87
93
  // aqui para não virar texto digitado; a tela pai trata o mesmo evento
88
94
  // via seu próprio `useInput()`. Só bloqueia o caractere isolado, não
89
95
  // uma sequência colada maior que o contenha.
96
+ //
97
+ // Sem consumidor em produção hoje: o caso original ("f" ciclando o filtro
98
+ // da home) foi resolvido pelo caminho inverso — os ATALHOS é que se
99
+ // suspendem enquanto há campo ativo (`../hooks/use-typing-guard.ts`), o
100
+ // que cobre qualquer tecla sem a tela precisar declarar uma lista. A prop
101
+ // segue disponível e testada para o caso de uma tela precisar reservar
102
+ // uma tecla ESPECÍFICA mantendo os demais atalhos vivos.
90
103
  if (reservedCharsRef.current.includes(input)) {
91
104
  return;
92
105
  }
93
106
  if (input.length > 0) {
94
- onChangeRef.current(valueRef.current + input);
107
+ const next = valueRef.current + input;
108
+ valueRef.current = next;
109
+ onChangeRef.current(next);
95
110
  }
96
111
  }, []);
112
+ // Suspende os atalhos de LETRA enquanto este campo captura teclado: o Ink
113
+ // entrega a tecla a todos os handlers, então sem isto um `q` digitado aqui
114
+ // dispararia o atalho global de sair (ver ../hooks/use-typing-guard.ts).
115
+ useTypingGuard(isActive);
97
116
  useInput(handleInput, { isActive });
98
117
  // Reason: cursor em bloco simples (inversão de cor, sem literal — não
99
118
  // conta como cor hardcoded) só quando o campo está ativo, reforçando
@@ -27,6 +27,10 @@ export interface Glyphs {
27
27
  readonly arrowUp: string;
28
28
  /** Seta "para baixo" das dicas de navegação. */
29
29
  readonly arrowDown: string;
30
+ /** Seta "para a esquerda" (página anterior nas listas longas). */
31
+ readonly arrowLeft: string;
32
+ /** Seta "para a direita" (próxima página nas listas longas). */
33
+ readonly arrowRight: string;
30
34
  /** Caractere da máscara de senha/api_key (`components/text-input.tsx`). */
31
35
  readonly maskBullet: string;
32
36
  /** Frames do spinner (`components/spinner.tsx`). */
@@ -58,6 +58,8 @@ export const UNICODE_GLYPHS = {
58
58
  emptyPlaceholder: '—',
59
59
  arrowUp: '↑',
60
60
  arrowDown: '↓',
61
+ arrowLeft: '←',
62
+ arrowRight: '→',
61
63
  maskBullet: '•',
62
64
  spinnerFrames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
63
65
  gaugeFull: '█',
@@ -70,6 +72,8 @@ export const ASCII_GLYPHS = {
70
72
  emptyPlaceholder: '-',
71
73
  arrowUp: '^',
72
74
  arrowDown: 'v',
75
+ arrowLeft: '<',
76
+ arrowRight: '>',
73
77
  maskBullet: '*',
74
78
  spinnerFrames: ['|', '/', '-', '\\'],
75
79
  gaugeFull: '#',
@@ -1,8 +1,17 @@
1
- import { fetchIssueSearch, resolveApiKey } from '../../../index.js';
1
+ import { fetchIssueSearch, resolveApiKey, type SearchListItem } from '../../../index.js';
2
2
  /** Debounce (ms) default aplicado a `query` antes de disparar a busca. */
3
3
  export declare const DEFAULT_DEBOUNCE_MS = 300;
4
4
  /** Filtro rápido de status (tecla `f` cicla entre os três, ver `../screens/home.tsx`). */
5
- export type SearchStatusFilter = 'open' | 'closed' | 'all';
5
+ /**
6
+ * Filtro de status da home/busca.
7
+ *
8
+ * `'all'`/`'open'`/`'closed'` são os agregados do Redmine; um NÚMERO é o id de
9
+ * um status específico da instância (`/issue_statuses.json`). Os agregados
10
+ * sozinhos não serviam: uma instância real tem uma dezena de status (Nova,
11
+ * Fila, Estimativa, Atribuída, Em Andamento, Validação...) e todos eles são
12
+ * "abertos" — alternar aberto/fechado devolvia exatamente a mesma lista.
13
+ */
14
+ export type SearchStatusFilter = 'open' | 'closed' | 'all' | number;
6
15
  /**
7
16
  * Estado da busca, consumido por `../screens/home.tsx`. Mesmo vocabulário de
8
17
  * `MyIssuesState` (`use-my-issues.ts`) + um `idle` inicial próprio: a busca
@@ -16,7 +25,10 @@ export type IssueSearchState = {
16
25
  status: 'loading';
17
26
  } | {
18
27
  status: 'loaded';
28
+ /** Markdown do bundle (com as fences) — mantido para quem precisar dele. */
19
29
  content: string;
30
+ /** Itens ESTRUTURADOS: é o que a tela renderiza (ver ../screens/home.tsx). */
31
+ items: readonly SearchListItem[];
20
32
  count: number;
21
33
  degraded: boolean;
22
34
  warnings: string[];
@@ -55,6 +67,17 @@ export interface UseIssueSearchResult {
55
67
  */
56
68
  clear: () => void;
57
69
  }
70
+ /**
71
+ * Mapeia o filtro rápido de status para o `status_id` de `/issues.json`
72
+ * (`*` = todas).
73
+ *
74
+ * Exportada porque a LISTA da home (`./use-my-issues.ts`) aplica o mesmo filtro
75
+ * que a busca — duas traduções separadas divergiriam.
76
+ *
77
+ * @param filter - Filtro rápido escolhido na tela.
78
+ * @returns O valor de `status_id` para a query.
79
+ */
80
+ export declare function statusIdFor(filter: SearchStatusFilter): string;
58
81
  /**
59
82
  * Busca issues (filtros + full-text best-effort) via o core, com debounce de
60
83
  * digitação e o filtro rápido de status.
@@ -34,8 +34,19 @@ import { useEnvFallbackAllowed } from '../instance.js';
34
34
  import { ReAuthAbortedError, useAuthGuard } from './use-auth-guard.js';
35
35
  /** Debounce (ms) default aplicado a `query` antes de disparar a busca. */
36
36
  export const DEFAULT_DEBOUNCE_MS = 300;
37
- /** Mapeia o filtro rápido de status para o `status_id` de `/issues.json` (`*` = todas). */
38
- function statusIdFor(filter) {
37
+ /**
38
+ * Mapeia o filtro rápido de status para o `status_id` de `/issues.json`
39
+ * (`*` = todas).
40
+ *
41
+ * Exportada porque a LISTA da home (`./use-my-issues.ts`) aplica o mesmo filtro
42
+ * que a busca — duas traduções separadas divergiriam.
43
+ *
44
+ * @param filter - Filtro rápido escolhido na tela.
45
+ * @returns O valor de `status_id` para a query.
46
+ */
47
+ export function statusIdFor(filter) {
48
+ if (typeof filter === 'number')
49
+ return String(filter);
39
50
  if (filter === 'open')
40
51
  return 'open';
41
52
  if (filter === 'closed')
@@ -124,6 +135,7 @@ export function useIssueSearch(query, statusFilter, options = {}) {
124
135
  setState({
125
136
  status: 'loaded',
126
137
  content: result.content,
138
+ items: result.items,
127
139
  count: result.count,
128
140
  degraded: result.degraded,
129
141
  warnings: result.warnings,
@@ -14,6 +14,14 @@ export interface UseListNavigationOptions {
14
14
  * inline da home, M2-07/#30). Default: `true`.
15
15
  */
16
16
  isActive?: boolean;
17
+ /**
18
+ * Itens saltados por uma "página" (setas ESQUERDA/DIREITA e PageUp/PageDown).
19
+ *
20
+ * Navegar item a item não escala: uma lista filtrada por status pode ter
21
+ * centenas de entradas. A tela deve passar a ALTURA da sua janela visível,
22
+ * para que uma página corresponda a uma tela cheia. Default: 10.
23
+ */
24
+ pageSize?: number;
17
25
  /**
18
26
  * Cursor inicial — default `0`. Aplicado assim que `itemCount` deixa de
19
27
  * ser `0` pela primeira vez (clampado); mudanças subsequentes NÃO
@@ -27,7 +27,7 @@ import { useEffect, useRef, useState } from 'react';
27
27
  * });
28
28
  */
29
29
  export function useListNavigation(itemCount, options = {}) {
30
- const { onSelect, isActive = true, initialIndex } = options;
30
+ const { onSelect, isActive = true, initialIndex, pageSize = 10 } = options;
31
31
  // Ref (não dep de efeito): a aplicação do `initialIndex` deve acontecer uma
32
32
  // única vez, na primeira vez que a lista deixa de estar vazia — mesmo que
33
33
  // `initialIndex` mude entre renders (ex.: o contexto de origem re-renderiza
@@ -54,6 +54,17 @@ export function useListNavigation(itemCount, options = {}) {
54
54
  setSelectedIndex((current) => (current + 1) % itemCount);
55
55
  return;
56
56
  }
57
+ // Página: ao contrário de ↑/↓, NÃO dá a volta — saltar do topo para o fim
58
+ // da lista desorienta. Para nas bordas, como em qualquer paginador.
59
+ const page = Math.max(1, pageSize);
60
+ if (key.leftArrow || key.pageUp) {
61
+ setSelectedIndex((current) => Math.max(0, current - page));
62
+ return;
63
+ }
64
+ if (key.rightArrow || key.pageDown) {
65
+ setSelectedIndex((current) => Math.min(itemCount - 1, current + page));
66
+ return;
67
+ }
57
68
  if (key.return) {
58
69
  onSelect?.(selectedIndex);
59
70
  }
@@ -1,4 +1,5 @@
1
1
  import { createHttpClient, listIssues, resolveApiKey } from '../../../index.js';
2
+ import { type SearchStatusFilter } from './use-issue-search.js';
2
3
  /** Issue resumida exibida numa linha da lista da home — só os campos usados por `IssueRow`. */
3
4
  export interface MyIssue {
4
5
  /** Id numérico da issue (`#id` na linha). */
@@ -37,6 +38,14 @@ export type MyIssuesState = {
37
38
  };
38
39
  /** Dependências injetáveis do hook — todas opcionais, com defaults de produção via o core. */
39
40
  export interface UseMyIssuesOptions {
41
+ /**
42
+ * Filtro rápido de status (`f` na home). Default `'all'`.
43
+ *
44
+ * A LISTA precisa respeitá-lo: antes, o filtro só alimentava a busca — cujos
45
+ * resultados só aparecem com a busca ABERTA —, então ciclar o status não
46
+ * mudava nada na tela.
47
+ */
48
+ statusFilter?: SearchStatusFilter;
40
49
  /** Ambiente consultado para `REDMINE_URL`; default `process.env`. */
41
50
  env?: NodeJS.ProcessEnv;
42
51
  /** Resolve a api_key pela cascata M2; default `resolveApiKey` do core. */
@@ -32,6 +32,7 @@ import { useCallback, useEffect, useState } from 'react';
32
32
  import { createHttpClient, listIssues, resolveApiKey, RedmineForbiddenError, } from '../../../index.js';
33
33
  import { useEnvFallbackAllowed } from '../instance.js';
34
34
  import { ReAuthAbortedError, useAuthGuard } from './use-auth-guard.js';
35
+ import { statusIdFor } from './use-issue-search.js';
35
36
  /** Estreita `unknown` para um objeto indexável, ou `undefined` se não for. */
36
37
  function asRecord(value) {
37
38
  return typeof value === 'object' && value !== null ? value : undefined;
@@ -65,6 +66,7 @@ function instanceFromEnv(env) {
65
66
  * const { state, retry } = useMyIssues(); // deps de produção (process.env, core real)
66
67
  */
67
68
  export function useMyIssues(options = {}) {
69
+ const statusFilter = options.statusFilter ?? 'all';
68
70
  const env = options.env ?? process.env;
69
71
  const resolve = options.resolveApiKey ?? resolveApiKey;
70
72
  const buildClient = options.createHttpClient ?? createHttpClient;
@@ -109,7 +111,9 @@ export function useMyIssues(options = {}) {
109
111
  // Fix do review #120: envolvida por `guard()` — em 401, a Promise só
110
112
  // resolve após o re-login ter sucesso e a busca ser refeita
111
113
  // automaticamente; o estado do hook permanece `loading` enquanto isso.
112
- const raw = await guard(() => fetchIssues(http, { filters: { assigned_to_id: 'me' } }));
114
+ const raw = await guard(() => fetchIssues(http, {
115
+ filters: { assigned_to_id: 'me', status_id: statusIdFor(statusFilter) },
116
+ }));
113
117
  if (cancelled)
114
118
  return;
115
119
  const issues = raw.map(toMyIssue).filter((issue) => issue !== undefined);
@@ -146,6 +150,6 @@ export function useMyIssues(options = {}) {
146
150
  // entre renders com as deps de produção (defaults do módulo, ou
147
151
  // `process.env`, ou a identidade estável de `useAuthGuard().guard`) — o
148
152
  // efeito só precisa refazer a busca quando `reloadToken` muda (retry).
149
- }, [env, allowEnvFallback, resolve, buildClient, fetchIssues, guard, reloadToken]);
153
+ }, [statusFilter, env, allowEnvFallback, resolve, buildClient, fetchIssues, guard, reloadToken]);
150
154
  return { state, retry };
151
155
  }
@@ -0,0 +1,38 @@
1
+ import { createHttpClient, fetchEnumerations, resolveApiKey } from '../../../index.js';
2
+ import type { SearchStatusFilter } from './use-issue-search.js';
3
+ /** Uma opção do seletor: o valor do filtro + o rótulo exibido. */
4
+ export interface StatusOption {
5
+ /** Valor aplicado ao filtro. */
6
+ readonly value: SearchStatusFilter;
7
+ /** Rótulo legível (nome do status na instância, ou o agregado). */
8
+ readonly label: string;
9
+ }
10
+ /** Opções injetáveis (testes não tocam a rede). */
11
+ export interface UseStatusOptionsOptions {
12
+ /** Ambiente consultado para `REDMINE_URL`; default `process.env`. */
13
+ env?: NodeJS.ProcessEnv;
14
+ /** Resolve a api_key pela cascata; default `resolveApiKey` do core. */
15
+ resolveApiKey?: typeof resolveApiKey;
16
+ /** Constrói o client HTTP; default `createHttpClient` do core. */
17
+ createHttpClient?: typeof createHttpClient;
18
+ /** Busca as enumerações; default `fetchEnumerations` do core. */
19
+ fetchEnumerations?: typeof fetchEnumerations;
20
+ }
21
+ /**
22
+ * Opções do filtro de status: agregados + os status reais da instância.
23
+ *
24
+ * @param options - Dependências injetáveis (ver {@link UseStatusOptionsOptions}).
25
+ * @returns A lista de opções; só os agregados enquanto carrega ou se falhar.
26
+ * @example
27
+ * const options = useStatusOptions();
28
+ * // [{ value: 'all', label: 'Todas' }, ..., { value: 7, label: 'Atribuída' }]
29
+ */
30
+ export declare function useStatusOptions(options?: UseStatusOptionsOptions): readonly StatusOption[];
31
+ /**
32
+ * Rótulo de um filtro, resolvido contra as opções carregadas.
33
+ *
34
+ * @param options - Opções disponíveis.
35
+ * @param filter - Filtro corrente.
36
+ * @returns O rótulo da opção, ou `#id` se o status não constar (degradação).
37
+ */
38
+ export declare function statusFilterLabel(options: readonly StatusOption[], filter: SearchStatusFilter): string;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Opções do filtro de status da home (#30, revisto).
3
+ *
4
+ * O ciclo `todas → abertas → fechadas` não servia a uma instância real: todos os
5
+ * status de trabalho (Nova, Fila, Estimativa, Atribuída, Em Andamento,
6
+ * Validação…) são ABERTOS, então alternar entre "todas" e "abertas" devolvia a
7
+ * mesma lista e o filtro parecia quebrado.
8
+ *
9
+ * Este hook carrega os status DA INSTÂNCIA (`/issue_statuses.json`, já memoizado
10
+ * por `fetchEnumerations`) e os oferece como opções, com os dois agregados úteis
11
+ * no topo. Degrada em silêncio: sem permissão ou sem rede, sobram os agregados —
12
+ * o mesmo comportamento de antes, nunca um erro na tela.
13
+ */
14
+ import { useEffect, useState } from 'react';
15
+ import { createHttpClient, fetchEnumerations, resolveApiKey, } from '../../../index.js';
16
+ import { useEnvFallbackAllowed } from '../instance.js';
17
+ /** Agregados sempre disponíveis, mesmo sem as enumerações da instância. */
18
+ const BASE_OPTIONS = [
19
+ { value: 'all', label: 'Todas' },
20
+ { value: 'open', label: 'Abertas' },
21
+ { value: 'closed', label: 'Fechadas' },
22
+ ];
23
+ /** Lê `REDMINE_URL` do ambiente, tratando string vazia como ausente. */
24
+ function instanceFromEnv(env) {
25
+ const value = env.REDMINE_URL;
26
+ return value !== undefined && value.length > 0 ? value : undefined;
27
+ }
28
+ /**
29
+ * Opções do filtro de status: agregados + os status reais da instância.
30
+ *
31
+ * @param options - Dependências injetáveis (ver {@link UseStatusOptionsOptions}).
32
+ * @returns A lista de opções; só os agregados enquanto carrega ou se falhar.
33
+ * @example
34
+ * const options = useStatusOptions();
35
+ * // [{ value: 'all', label: 'Todas' }, ..., { value: 7, label: 'Atribuída' }]
36
+ */
37
+ export function useStatusOptions(options = {}) {
38
+ const env = options.env ?? process.env;
39
+ const resolve = options.resolveApiKey ?? resolveApiKey;
40
+ const buildClient = options.createHttpClient ?? createHttpClient;
41
+ const loadEnumerations = options.fetchEnumerations ?? fetchEnumerations;
42
+ const allowEnvFallback = useEnvFallbackAllowed();
43
+ const [statuses, setStatuses] = useState([]);
44
+ useEffect(() => {
45
+ let cancelled = false;
46
+ const instanceUrl = instanceFromEnv(env);
47
+ if (instanceUrl === undefined)
48
+ return undefined;
49
+ void (async () => {
50
+ try {
51
+ const cascade = { env, allowEnvFallback };
52
+ const apiKey = await resolve(instanceUrl, cascade);
53
+ if (apiKey === undefined || cancelled)
54
+ return;
55
+ const enums = await loadEnumerations(buildClient({ baseUrl: instanceUrl, apiKey }), instanceUrl);
56
+ if (cancelled)
57
+ return;
58
+ // Ordem estável por id: a lista não pode dançar entre renders.
59
+ setStatuses([...enums.status.entries()]
60
+ .sort(([a], [b]) => a - b)
61
+ .map(([id, label]) => ({ value: id, label })));
62
+ }
63
+ catch {
64
+ // Enriquecimento, não dado essencial: sem os status da instância o
65
+ // seletor segue com os agregados.
66
+ }
67
+ })();
68
+ return () => {
69
+ cancelled = true;
70
+ };
71
+ }, [env, allowEnvFallback, resolve, buildClient, loadEnumerations]);
72
+ return statuses.length === 0 ? BASE_OPTIONS : [...BASE_OPTIONS, ...statuses];
73
+ }
74
+ /**
75
+ * Rótulo de um filtro, resolvido contra as opções carregadas.
76
+ *
77
+ * @param options - Opções disponíveis.
78
+ * @param filter - Filtro corrente.
79
+ * @returns O rótulo da opção, ou `#id` se o status não constar (degradação).
80
+ */
81
+ export function statusFilterLabel(options, filter) {
82
+ const found = options.find((option) => option.value === filter);
83
+ if (found !== undefined)
84
+ return found.label;
85
+ return typeof filter === 'number' ? `#${filter}` : filter;
86
+ }
@@ -34,6 +34,22 @@ export declare function TerminalWidthProvider({ width, children }: {
34
34
  * consumidor (ex.: cada linha da lista) assinar o próprio `resize` — o que
35
35
  * estourava o limite de 10 listeners do EventEmitter (MaxListenersExceededWarning).
36
36
  */
37
+ /**
38
+ * Sobrescreve a ALTURA disponível para as telas abaixo.
39
+ *
40
+ * Usado pelo shell (`../app.tsx`) para descontar as linhas que a moldura da
41
+ * aplicação consome: as telas pedem `useTerminalHeight()` e devem receber o
42
+ * espaço que sobra, sem precisar saber que existe uma moldura — do contrário
43
+ * cada tela carregaria uma constante acoplada ao desenho do shell (e os testes,
44
+ * que montam telas sem moldura, veriam um valor errado).
45
+ *
46
+ * @param props.height - Altura disponível, em linhas.
47
+ * @param props.children - Subárvore que passa a enxergar essa altura.
48
+ */
49
+ export declare function TerminalHeightProvider({ height, children }: {
50
+ height: number;
51
+ children: ReactNode;
52
+ }): import("react").JSX.Element;
37
53
  export declare function TerminalSizeProvider({ children }: {
38
54
  children: ReactNode;
39
55
  }): import("react").JSX.Element;
@@ -47,6 +47,21 @@ export function TerminalWidthProvider({ width, children }) {
47
47
  * consumidor (ex.: cada linha da lista) assinar o próprio `resize` — o que
48
48
  * estourava o limite de 10 listeners do EventEmitter (MaxListenersExceededWarning).
49
49
  */
50
+ /**
51
+ * Sobrescreve a ALTURA disponível para as telas abaixo.
52
+ *
53
+ * Usado pelo shell (`../app.tsx`) para descontar as linhas que a moldura da
54
+ * aplicação consome: as telas pedem `useTerminalHeight()` e devem receber o
55
+ * espaço que sobra, sem precisar saber que existe uma moldura — do contrário
56
+ * cada tela carregaria uma constante acoplada ao desenho do shell (e os testes,
57
+ * que montam telas sem moldura, veriam um valor errado).
58
+ *
59
+ * @param props.height - Altura disponível, em linhas.
60
+ * @param props.children - Subárvore que passa a enxergar essa altura.
61
+ */
62
+ export function TerminalHeightProvider({ height, children }) {
63
+ return _jsx(TerminalHeightContext.Provider, { value: height, children: children });
64
+ }
50
65
  export function TerminalSizeProvider({ children }) {
51
66
  const [size, setSize] = useState(() => ({ width: readProcessColumns(), height: readProcessRows() }));
52
67
  useEffect(() => {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Há algum campo de texto ativo agora?
3
+ *
4
+ * Chamado pelos handlers de atalho ANTES de reagir a uma tecla de letra.
5
+ *
6
+ * @returns `true` enquanto qualquer `TextInput` ativo estiver montado.
7
+ */
8
+ export declare function isTyping(): boolean;
9
+ /** Zera o contador — para os testes não vazarem estado entre casos. */
10
+ export declare function resetTypingGuard(): void;
11
+ /**
12
+ * Registra um campo de texto como ativo enquanto `active` for `true`.
13
+ *
14
+ * @param active - `true` enquanto o campo captura teclado.
15
+ * @example
16
+ * // dentro de um componente de input:
17
+ * useTypingGuard(isActive);
18
+ */
19
+ export declare function useTypingGuard(active: boolean): void;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Guarda de DIGITAÇÃO para os atalhos de letra.
3
+ *
4
+ * O Ink entrega cada tecla a TODOS os `useInput` registrados na árvore — um
5
+ * campo de texto ativo não "consome" a tecla. Sem esta guarda, qualquer atalho
6
+ * global de letra dispara no meio da digitação: `q` (sair) fecha a TUI enquanto
7
+ * se digita a URL da instância no onboarding (`https://redmine.qualquer...`) ou
8
+ * uma senha que contenha a letra.
9
+ *
10
+ * As telas que já sabem quando estão em modo de texto (a busca da home) checam
11
+ * o próprio estado; este módulo cobre o caso GERAL — qualquer `TextInput` ativo
12
+ * em qualquer tela suspende os atalhos de letra enquanto durar.
13
+ *
14
+ * Mesmo desenho de `./use-escape-interceptor.ts`: estado mutável em nível de
15
+ * módulo, não Context/Provider — a TUI sustenta uma tela por vez e um Provider
16
+ * seria complexidade sem benefício. Aqui é um CONTADOR (não um booleano) porque
17
+ * uma tela pode montar mais de um campo (ex.: usuário + senha) e a ordem de
18
+ * montagem/desmontagem entre eles não é garantida.
19
+ *
20
+ * Atalhos com modificador (`Ctrl+C`) continuam livres: eles não colidem com
21
+ * texto. `Command+<letra>` não é alternativa em TUI — no macOS o terminal
22
+ * intercepta antes de a tecla chegar à aplicação.
23
+ */
24
+ import { useEffect } from 'react';
25
+ /** Quantidade de campos de texto ATIVOS no momento. */
26
+ let activeInputs = 0;
27
+ /**
28
+ * Há algum campo de texto ativo agora?
29
+ *
30
+ * Chamado pelos handlers de atalho ANTES de reagir a uma tecla de letra.
31
+ *
32
+ * @returns `true` enquanto qualquer `TextInput` ativo estiver montado.
33
+ */
34
+ export function isTyping() {
35
+ return activeInputs > 0;
36
+ }
37
+ /** Zera o contador — para os testes não vazarem estado entre casos. */
38
+ export function resetTypingGuard() {
39
+ activeInputs = 0;
40
+ }
41
+ /**
42
+ * Registra um campo de texto como ativo enquanto `active` for `true`.
43
+ *
44
+ * @param active - `true` enquanto o campo captura teclado.
45
+ * @example
46
+ * // dentro de um componente de input:
47
+ * useTypingGuard(isActive);
48
+ */
49
+ export function useTypingGuard(active) {
50
+ useEffect(() => {
51
+ if (!active)
52
+ return undefined;
53
+ activeInputs += 1;
54
+ return () => {
55
+ activeInputs = Math.max(0, activeInputs - 1);
56
+ };
57
+ }, [active]);
58
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Janela visível de uma lista longa, seguindo o cursor.
3
+ *
4
+ * A home renderizava TODAS as issues de uma vez. Enquanto a lista era curta
5
+ * (as abertas de uma pessoa) isso passava; filtrando por um status com centenas
6
+ * de itens, a lista estoura a altura do terminal, o rodapé é empurrado para fora
7
+ * e o cursor — que começa no topo — some da tela: "cadê meu cursor".
8
+ *
9
+ * Esta é a contraparte de `./components/scroll-view.tsx` para listas
10
+ * SELECIONÁVEIS: lá o deslocamento é dirigido pelo teclado sobre conteúdo
11
+ * estático; aqui ele acompanha o índice selecionado, que é quem manda.
12
+ *
13
+ * Sem estado próprio de propósito: a janela é função do (total, selecionado,
14
+ * altura). Guardar um offset exigiria sincronizá-lo com a seleção, com a troca
15
+ * de filtro e com o redimensionamento do terminal — três fontes de divergência.
16
+ */
17
+ /** Intervalo `[start, end)` de itens a renderizar. */
18
+ export interface ListWindow {
19
+ /** Índice do primeiro item visível. */
20
+ readonly start: number;
21
+ /** Índice logo APÓS o último visível (exclusivo, pronto para `slice`). */
22
+ readonly end: number;
23
+ }
24
+ /**
25
+ * Calcula a fatia visível mantendo o item selecionado dentro dela.
26
+ *
27
+ * O cursor é mantido ao MEIO da janela sempre que possível; nas pontas a janela
28
+ * gruda no início ou no fim, para não sobrar espaço vazio.
29
+ *
30
+ * @param total - Quantidade de itens da lista.
31
+ * @param selected - Índice selecionado (fora da faixa é fixado nela).
32
+ * @param height - Linhas disponíveis; `<= 0` devolve uma janela vazia.
33
+ * @returns O intervalo `[start, end)` para `slice`.
34
+ * @example
35
+ * listWindow(347, 0, 20); // { start: 0, end: 20 }
36
+ * listWindow(347, 200, 20); // { start: 190, end: 210 } — cursor centrado
37
+ * listWindow(347, 346, 20); // { start: 327, end: 347 } — grudado no fim
38
+ */
39
+ export declare function listWindow(total: number, selected: number, height: number): ListWindow;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Janela visível de uma lista longa, seguindo o cursor.
3
+ *
4
+ * A home renderizava TODAS as issues de uma vez. Enquanto a lista era curta
5
+ * (as abertas de uma pessoa) isso passava; filtrando por um status com centenas
6
+ * de itens, a lista estoura a altura do terminal, o rodapé é empurrado para fora
7
+ * e o cursor — que começa no topo — some da tela: "cadê meu cursor".
8
+ *
9
+ * Esta é a contraparte de `./components/scroll-view.tsx` para listas
10
+ * SELECIONÁVEIS: lá o deslocamento é dirigido pelo teclado sobre conteúdo
11
+ * estático; aqui ele acompanha o índice selecionado, que é quem manda.
12
+ *
13
+ * Sem estado próprio de propósito: a janela é função do (total, selecionado,
14
+ * altura). Guardar um offset exigiria sincronizá-lo com a seleção, com a troca
15
+ * de filtro e com o redimensionamento do terminal — três fontes de divergência.
16
+ */
17
+ /**
18
+ * Calcula a fatia visível mantendo o item selecionado dentro dela.
19
+ *
20
+ * O cursor é mantido ao MEIO da janela sempre que possível; nas pontas a janela
21
+ * gruda no início ou no fim, para não sobrar espaço vazio.
22
+ *
23
+ * @param total - Quantidade de itens da lista.
24
+ * @param selected - Índice selecionado (fora da faixa é fixado nela).
25
+ * @param height - Linhas disponíveis; `<= 0` devolve uma janela vazia.
26
+ * @returns O intervalo `[start, end)` para `slice`.
27
+ * @example
28
+ * listWindow(347, 0, 20); // { start: 0, end: 20 }
29
+ * listWindow(347, 200, 20); // { start: 190, end: 210 } — cursor centrado
30
+ * listWindow(347, 346, 20); // { start: 327, end: 347 } — grudado no fim
31
+ */
32
+ export function listWindow(total, selected, height) {
33
+ if (height <= 0 || total <= 0)
34
+ return { start: 0, end: 0 };
35
+ if (total <= height)
36
+ return { start: 0, end: total };
37
+ const cursor = Math.min(Math.max(selected, 0), total - 1);
38
+ const half = Math.floor(height / 2);
39
+ const start = Math.min(Math.max(0, cursor - half), total - height);
40
+ return { start, end: start + height };
41
+ }
@@ -53,6 +53,7 @@ import { useExportBundle } from '../hooks/use-export-bundle.js';
53
53
  import { useListNavigation } from '../hooks/use-list-navigation.js';
54
54
  import { useJobRegistry } from '../job-registry.js';
55
55
  import { useTerminalWidth } from '../hooks/use-terminal-width.js';
56
+ import { isTyping } from '../hooks/use-typing-guard.js';
56
57
  import { useNavigation } from '../navigation.js';
57
58
  import { symbols } from '../symbols.js';
58
59
  import { useTheme } from '../theme.js';
@@ -154,8 +155,11 @@ export function ExportScreen() {
154
155
  return;
155
156
  }
156
157
  // "b" não interrompe uma exportação em andamento — evita voltar no meio
157
- // de uma gravação em disco.
158
- if (input === 'b' && stateStatusRef.current !== 'exporting') {
158
+ // de uma gravação em disco — nem dispara enquanto o campo "Destino" está
159
+ // capturando teclado: o Ink entrega a tecla aos dois handlers, então um
160
+ // caminho como `~/backup/` ou `bundle.json` fecharia a tela no meio da
161
+ // digitação (ver ../hooks/use-typing-guard.ts).
162
+ if (input === 'b' && !isTyping() && stateStatusRef.current !== 'exporting') {
159
163
  popRef.current();
160
164
  return;
161
165
  }
@@ -21,7 +21,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
21
21
  * retry.
22
22
  *
23
23
  * M2-07 (#30) acrescenta a busca/filtros inline: `/` abre um `TextInput`
24
- * QUANDO a home está ativa; `f` cicla o filtro com a busca FECHADA (badge no
24
+ * QUANDO a home está ativa; `f` abre o seletor de status com a busca FECHADA (badge no
25
25
  * cabeçalho); Esc fecha a busca sem refetch, interceptado via
26
26
  * `../hooks/use-escape-interceptor.ts` para não desempilhar a home.
27
27
  *
@@ -43,12 +43,15 @@ import { Spinner } from '../components/spinner.js';
43
43
  import { TextInput } from '../components/text-input.js';
44
44
  import { glyphs } from '../glyphs.js';
45
45
  import { useEscapeInterceptor } from '../hooks/use-escape-interceptor.js';
46
+ import { isTyping } from '../hooks/use-typing-guard.js';
47
+ import { listWindow } from '../list-window.js';
48
+ import { statusFilterLabel, useStatusOptions } from '../hooks/use-status-options.js';
46
49
  import { useIssueSearch } from '../hooks/use-issue-search.js';
47
50
  import { useListNavigation } from '../hooks/use-list-navigation.js';
48
51
  import { useMyIssues } from '../hooks/use-my-issues.js';
49
- import { useTerminalWidth } from '../hooks/use-terminal-width.js';
52
+ import { useTerminalHeight, useTerminalWidth } from '../hooks/use-terminal-width.js';
50
53
  import { useNavigation } from '../navigation.js';
51
- import { statusColor } from '../status-color.js';
54
+ import { statusColor, statusFilterColor } from '../status-color.js';
52
55
  import { symbols } from '../symbols.js';
53
56
  import { useTheme } from '../theme.js';
54
57
  import { truncate } from '../truncate.js';
@@ -61,20 +64,14 @@ const SCREEN_PADDING_X = 2;
61
64
  // literal `[]` inline em cada render seria recriado a cada chamada,
62
65
  // invalidando memoizações a jusante (`useListNavigation`) sem necessidade.
63
66
  const EMPTY_ISSUES = [];
64
- /** Rótulo do badge de cada filtro rápido de status (tecla `f` cicla, M2-07/#30). */
65
- const STATUS_FILTER_LABELS = {
66
- open: 'aberta',
67
- closed: 'fechada',
68
- all: 'todas',
69
- };
70
- /** Próximo filtro no ciclo `aberta fechada → todas → aberta` (tecla `f`). */
71
- function nextStatusFilter(current) {
72
- if (current === 'open')
73
- return 'closed';
74
- if (current === 'closed')
75
- return 'all';
76
- return 'open';
77
- }
67
+ /**
68
+ * Linhas ocupadas fora da lista NESTA tela (breadcrumb, cabeçalho, contador,
69
+ * rodapé, paddings). A moldura da aplicação não entra: o shell já entrega a
70
+ * altura sem ela (ver `TerminalHeightProvider` em `../app.tsx`).
71
+ */
72
+ const LIST_OVERHEAD_ROWS = 9;
73
+ /** Piso da janela da lista (terminais muito baixos). */
74
+ const LIST_MIN_HEIGHT = 5;
78
75
  /**
79
76
  * Espaço fixo ocupado pela linha FORA do subject (M2-16, #39): ponteiro (2),
80
77
  * `#id ` (id + `#`/espaço), 1 espaço final após o subject, `[status]`
@@ -89,6 +86,20 @@ function fixedRowOverhead(idLength, statusLength) {
89
86
  return POINTER_WIDTH + ID_PREFIX_WIDTH + SUBJECT_TRAILING_SPACE + STATUS_BRACKETS_WIDTH + SCREEN_PADDING_X;
90
87
  }
91
88
  /** Uma linha da lista: `#id` em `theme.muted`, subject truncado ao orçamento de largura, badge de status. */
89
+ /**
90
+ * Uma linha de RESULTADO DE BUSCA.
91
+ *
92
+ * Espelha o layout do {@link IssueRow} (id · assunto · status), mas a partir do
93
+ * item estruturado da busca — sem passar pelo Markdown do bundle, que traria as
94
+ * fences `<untrusted-content>` para a tela.
95
+ */
96
+ function SearchResultRow({ item, selected }) {
97
+ const theme = useTheme();
98
+ const terminalWidth = useTerminalWidth();
99
+ const overhead = fixedRowOverhead(String(item.id).length, item.status.length);
100
+ const subjectBudget = Math.max(terminalWidth - overhead, MIN_SUBJECT_WIDTH);
101
+ return (_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: selected ? `${symbols.pointerSmall} ` : ' ' }), _jsxs(Text, { color: theme.muted, children: ["#", item.id, " "] }), _jsx(Text, { ...(selected ? { color: theme.primary } : {}), children: truncate(item.subject ?? '(sem assunto)', subjectBudget) }), _jsxs(Text, { color: statusColor(theme, item.status), children: [" [", item.status, "]"] }), _jsxs(Text, { color: theme.muted, children: [" ", item.assignee] })] }));
102
+ }
92
103
  function IssueRow({ issue, selected }) {
93
104
  const theme = useTheme();
94
105
  const terminalWidth = useTerminalWidth();
@@ -100,28 +111,64 @@ function IssueRow({ issue, selected }) {
100
111
  export function HomeScreen() {
101
112
  const theme = useTheme();
102
113
  const { push } = useNavigation();
103
- const { state, retry } = useMyIssues();
104
114
  // #31: índice preservado entre remounts + registro de qual issue foi aberta
105
115
  // (ver o JSDoc do módulo e de `./home-selection.js`).
106
116
  const { selectedIndex: persistedIndex, setSelectedIndex: persistIndex, setSelectedIssueId } = useHomeSelection();
107
117
  // --- Busca/filtros inline (M2-07, #30) ---
108
118
  const [isSearching, setIsSearching] = useState(false);
109
119
  const [query, setQuery] = useState('');
110
- const [statusFilter, setStatusFilter] = useState('all');
111
- const search = useIssueSearch(query, statusFilter);
120
+ // Default ABERTAS, não "todas": a home é a lista de trabalho. Com `all` o
121
+ // Redmine devolve o histórico inteiro (centenas de fechadas), que estoura a
122
+ // tela e enterra o que importa — era o default implícito antes do filtro
123
+ // chegar à lista (a API omite fechadas quando `status_id` não é enviado).
124
+ const [statusFilter, setStatusFilter] = useState('open');
125
+ // Seletor de status (#30, revisto): `f` abre uma LISTA em vez de ciclar às
126
+ // cegas — a instância tem uma dezena de status e o usuário não tem como
127
+ // adivinhar qual vem a seguir num ciclo.
128
+ const [isPickingStatus, setIsPickingStatus] = useState(false);
129
+ const statusOptions = useStatusOptions();
130
+ const [statusIndex, setStatusIndex] = useState(0);
131
+ // Seleção dentro dos RESULTADOS da busca: achar o chamado e não conseguir
132
+ // abrir é o mesmo que não ter achado. As setas não são texto, então navegam
133
+ // os resultados enquanto o campo continua recebendo letras.
134
+ const [searchIndex, setSearchIndex] = useState(0);
135
+ const searchIndexRef = useRef(searchIndex);
136
+ searchIndexRef.current = searchIndex;
137
+ // O filtro só vai para a BUSCA quando ela está aberta: com ela fechada, quem
138
+ // aplica o status é a lista (abaixo), e passar o filtro aqui dispararia um
139
+ // request cujo resultado nunca é renderizado — dois GETs por `f` em vez de um.
140
+ const search = useIssueSearch(query, isSearching ? statusFilter : 'all');
141
+ // O filtro rápido (`f`) precisa valer para a LISTA visível — antes ele só
142
+ // alimentava a busca, cujos resultados só aparecem com a busca ABERTA, então
143
+ // trocar o status não mudava nada na tela.
144
+ const { state, retry } = useMyIssues({ statusFilter });
112
145
  // Handler ESTÁVEL: `search.clear` é a única dependência mutável (mas já é
113
146
  // estável por construção, ver `use-issue-search.ts`) — fecha a busca e
114
147
  // restaura os 3 estados neutros de uma vez (query/filtro/hook de busca).
115
148
  const closeSearch = useCallback(() => {
116
149
  setIsSearching(false);
117
150
  setQuery('');
118
- setStatusFilter('all');
151
+ // O filtro de status NÃO é resetado: desde que ele governa a LISTA (e não
152
+ // só a busca), zerá-lo aqui desfazia uma escolha que o usuário fez ANTES de
153
+ // abrir a busca — `f`, depois `/`, depois `Esc` devolvia [Todas].
119
154
  search.clear();
120
155
  }, [search.clear]);
121
156
  // Desvia o Esc GLOBAL (`../app.tsx`) enquanto a busca está aberta — sem
122
157
  // isso, Esc desempilharia a home inteira em vez de só fechar a busca.
158
+ // Resultado novo, cursor no topo: manter o índice de uma busca anterior faria
159
+ // o Enter abrir uma issue que não é a que está sob o cursor.
160
+ useEffect(() => {
161
+ setSearchIndex(0);
162
+ searchIndexRef.current = 0;
163
+ }, [query, statusFilter]);
123
164
  useEscapeInterceptor(isSearching, closeSearch);
165
+ const closeStatusPicker = useCallback(() => setIsPickingStatus(false), []);
166
+ useEscapeInterceptor(isPickingStatus, closeStatusPicker);
124
167
  const issues = state.status === 'loaded' ? state.issues : EMPTY_ISSUES;
168
+ // Altura da lista: o terminal menos a moldura, breadcrumb, cabeçalho, contador
169
+ // e rodapé. A lista rola DENTRO dessa janela em vez de empurrar o resto da
170
+ // tela para fora — e a mesma altura é o salto de uma página.
171
+ const listHeight = Math.max(LIST_MIN_HEIGHT, useTerminalHeight() - LIST_OVERHEAD_ROWS);
125
172
  // Handlers ESTÁVEIS (useCallback + refs, padrão do repo): identidade nova a
126
173
  // cada render des/re-subscreve o useInput e pode perder uma tecla rápida.
127
174
  const pushRef = useRef(push);
@@ -139,8 +186,13 @@ export function HomeScreen() {
139
186
  // última posição persistida (sobrevive ao unmount via home-selection).
140
187
  const { selectedIndex } = useListNavigation(issues.length, {
141
188
  onSelect: handleSelect,
142
- isActive: !isSearching,
189
+ // Também desligada com o SELETOR DE STATUS aberto: o Ink entrega a tecla a
190
+ // todos os handlers, então o Enter que aplica o filtro abria a issue
191
+ // selecionada por baixo, ao mesmo tempo.
192
+ isActive: !isSearching && !isPickingStatus,
143
193
  initialIndex: persistedIndex,
194
+ // Uma página = uma tela da janela visível (ver ../list-window.ts).
195
+ pageSize: listHeight,
144
196
  });
145
197
  // Espelha `selectedIndex` ao contexto — a cópia externa sobrevive ao
146
198
  // unmount desta tela (ver ./home-selection.js).
@@ -155,6 +207,16 @@ export function HomeScreen() {
155
207
  statusRef.current = state.status;
156
208
  const isSearchingRef = useRef(isSearching);
157
209
  isSearchingRef.current = isSearching;
210
+ const isPickingStatusRef = useRef(isPickingStatus);
211
+ isPickingStatusRef.current = isPickingStatus;
212
+ const optionsRef = useRef(statusOptions);
213
+ optionsRef.current = statusOptions;
214
+ const statusIndexRef = useRef(statusIndex);
215
+ statusIndexRef.current = statusIndex;
216
+ const filterRef = useRef(statusFilter);
217
+ filterRef.current = statusFilter;
218
+ const searchItemsRef = useRef([]);
219
+ searchItemsRef.current = search.state.status === 'loaded' ? search.state.items : [];
158
220
  const handleRetryInput = useCallback((input) => {
159
221
  // M2-07 (#30): "r" digitado como texto de busca não deve disparar retry.
160
222
  if (isSearchingRef.current)
@@ -168,33 +230,99 @@ export function HomeScreen() {
168
230
  }
169
231
  }, []);
170
232
  useInput(handleRetryInput);
171
- // "/" abre a busca; "f" cicla o filtro rápido de status (com a busca
172
- // aberta). Reason: dentro do campo de texto, "f" digitado NÃO vira
173
- // caractere da queryé a tecla de controle do filtro, trade-off
174
- // Com a busca ABERTA, toda letra pertence à query (buscar "workflow" exige
175
- // digitar "f") — o ciclo de filtro por "f" só vale com a busca fechada.
233
+ // "/" abre a busca; "f" abre o seletor de status. Ambos FORA de campo de
234
+ // texto: com a busca aberta toda letra pertence à query (buscar "workflow"
235
+ // exige digitar "f")ver ../hooks/use-typing-guard.ts.
176
236
  const handleSearchControlInput = useCallback((input) => {
177
- if (input === '/' && !isSearchingRef.current) {
237
+ if (isPickingStatusRef.current)
238
+ return;
239
+ if (input === '/' && !isTyping()) {
178
240
  setIsSearching(true);
179
241
  return;
180
242
  }
181
- if (input === 'f' && !isSearchingRef.current) {
182
- setStatusFilter((current) => nextStatusFilter(current));
243
+ if (input === 'f' && !isTyping()) {
244
+ // Abre já posicionado no filtro atual, para o usuário ver onde está.
245
+ const start = Math.max(0, optionsRef.current.findIndex((o) => o.value === filterRef.current));
246
+ statusIndexRef.current = start;
247
+ setStatusIndex(start);
248
+ setIsPickingStatus(true);
183
249
  }
184
250
  }, []);
185
251
  useInput(handleSearchControlInput);
252
+ // Navegação do seletor de status.
253
+ const handleStatusPickerInput = useCallback((input, key) => {
254
+ if (!isPickingStatusRef.current)
255
+ return;
256
+ const total = optionsRef.current.length;
257
+ // A ref avança JUNTO com o estado: sincronizá-la só no render faz o
258
+ // `Enter` logo após um `j`/`k` ler o índice ANTIGO (o React ainda não
259
+ // commitou) e aplicar o filtro errado — mesmo defeito que o TextInput
260
+ // tinha ao perder teclas digitadas rápido.
261
+ const move = (delta) => {
262
+ const next = (statusIndexRef.current + delta + total) % total;
263
+ statusIndexRef.current = next;
264
+ setStatusIndex(next);
265
+ };
266
+ if (key.upArrow || input === 'k') {
267
+ move(-1);
268
+ return;
269
+ }
270
+ if (key.downArrow || input === 'j') {
271
+ move(1);
272
+ return;
273
+ }
274
+ if (key.return) {
275
+ const picked = optionsRef.current[statusIndexRef.current];
276
+ if (picked !== undefined)
277
+ setStatusFilter(picked.value);
278
+ setIsPickingStatus(false);
279
+ }
280
+ }, []);
281
+ useInput(handleStatusPickerInput);
282
+ // Navegação dos RESULTADOS da busca (setas + Enter). O campo de texto ignora
283
+ // setas e Enter, então não há disputa: as letras seguem indo para a query.
284
+ const handleSearchResultsInput = useCallback((_input, key) => {
285
+ const items = searchItemsRef.current;
286
+ if (!isSearchingRef.current || items.length === 0)
287
+ return;
288
+ if (key.upArrow) {
289
+ setSearchIndex((i) => (i - 1 + items.length) % items.length);
290
+ return;
291
+ }
292
+ if (key.downArrow) {
293
+ setSearchIndex((i) => (i + 1) % items.length);
294
+ return;
295
+ }
296
+ if (key.return) {
297
+ const picked = items[searchIndexRef.current];
298
+ if (picked !== undefined) {
299
+ setSelectedIssueIdRef.current(picked.id);
300
+ pushRef.current('issue-detail');
301
+ }
302
+ }
303
+ }, []);
304
+ useInput(handleSearchResultsInput);
186
305
  // #34 (M2-11): "t" abre o painel de jobs da sessão (`./jobs.js`) — só fora
187
306
  // da busca (mesma guarda de "/"/"f" acima: dentro do campo, "t" é texto da
188
307
  // query, não um atalho).
189
308
  const handleJobsShortcut = useCallback((input) => {
190
- if (input === 't' && !isSearchingRef.current) {
309
+ if (input === 't' && !isTyping()) {
191
310
  pushRef.current('jobs');
192
311
  }
193
312
  }, []);
194
313
  useInput(handleJobsShortcut);
314
+ const listWindow_ = listWindow(issues.length, selectedIndex, listHeight);
195
315
  const searchState = search.state;
196
- return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "Minhas issues" }), _jsxs(Text, { color: theme.muted, children: [" [", STATUS_FILTER_LABELS[statusFilter], "]"] }), !isSearching ? (_jsx(Text, { color: theme.muted, children: ` / busca ${glyphs.middleDot} f filtro ${glyphs.middleDot} t jobs` })) : null] }), isSearching ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "Buscar: " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: `digite para buscar${glyphs.ellipsis}`, isActive: isSearching }), _jsxs(Text, { color: theme.muted, children: [" [", STATUS_FILTER_LABELS[statusFilter], "]"] })] }), searchState.status === 'idle' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "Digite para buscar ou pressione f para ciclar o filtro de status." }) })) : null, searchState.status === 'loading' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Spinner, {}), " Buscando..."] }) })) : null, searchState.status === 'loaded' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [searchState.degraded ? (_jsxs(Text, { color: theme.warning, children: [symbols.warning, " ", searchState.warnings.join(' ')] })) : null, _jsx(Text, { children: searchState.content })] })) : null, searchState.status === 'error-network' || searchState.status === 'error-forbidden' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", searchState.message] }) })) : null, searchState.status === 'auth-aborted' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: searchState.message }) })) : null] })) : (_jsxs(_Fragment, { children: [state.status === 'loading' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Spinner, {}), " Carregando issues..."] }) })) : null, state.status === 'empty' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "nenhuma issue atribu\u00EDda" }) })) : null, state.status === 'error-network' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.danger, children: [symbols.cross, " Falha ao carregar suas issues: ", state.message] }), _jsxs(Text, { color: theme.muted, children: ["Pressione", ' ', _jsx(Text, { color: theme.accent, children: "r" }), ' ', "para tentar de novo."] })] })) : null, state.status === 'error-forbidden' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", state.message] }), _jsxs(Text, { color: theme.muted, children: ["Pressione", ' ', _jsx(Text, { color: theme.accent, children: "r" }), ' ', "para tentar de novo."] })] })) : null, state.status === 'auth-aborted' ? (
316
+ return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "Minhas issues" }), _jsxs(Text, { color: statusFilterColor(theme, statusFilter, statusFilterLabel(statusOptions, statusFilter)), children: [' ', "[", statusFilterLabel(statusOptions, statusFilter), "]"] }), state.status === 'loaded' ? (_jsx(Text, { color: theme.muted, children: ` ${glyphs.middleDot} ${issues.length} ${issues.length === 1 ? 'issue' : 'issues'}` })) : null, !isSearching && !isPickingStatus ? (_jsx(Text, { color: theme.muted, children: ` / busca ${glyphs.middleDot} f filtro ${glyphs.middleDot} t jobs` })) : null] }), isPickingStatus ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.primary, children: "Filtrar por status" }), statusOptions.map((option, i) => {
317
+ const selected = i === statusIndex;
318
+ const current = option.value === statusFilter;
319
+ // Divisor entre os AGREGADOS do Redmine e os status da instância:
320
+ // sem ele, "Fechadas" (agregado) e "Fechada" (status) ficam coladas
321
+ // e parecem duplicata.
322
+ const firstConcrete = typeof option.value === 'number' && typeof statusOptions[i - 1]?.value !== 'number';
323
+ return (_jsxs(Box, { flexDirection: "column", children: [firstConcrete ? (_jsx(Text, { color: theme.border, children: ` ${'─'.repeat(18)}` })) : null, _jsxs(Box, { children: [_jsxs(Text, { color: selected ? theme.primary : theme.muted, children: [selected ? symbols.pointer : ' ', ' '] }), _jsx(Text, { color: statusFilterColor(theme, option.value, option.label), children: option.label }), current ? _jsxs(Text, { color: theme.muted, children: [" ", symbols.tick, " atual"] }) : null] })] }, String(option.value)));
324
+ }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.muted, children: [_jsx(Text, { color: theme.accent, children: `${glyphs.arrowUp}/${glyphs.arrowDown}` }), " escolhe", ' ', glyphs.middleDot, " ", _jsx(Text, { color: theme.accent, children: "Enter" }), " aplica ", glyphs.middleDot, ' ', _jsx(Text, { color: theme.accent, children: "Esc" }), " cancela"] }) })] })) : null, isSearching ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "Buscar: " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: `digite para buscar${glyphs.ellipsis}`, isActive: isSearching }), _jsxs(Text, { color: statusFilterColor(theme, statusFilter, statusFilterLabel(statusOptions, statusFilter)), children: [' ', "[", statusFilterLabel(statusOptions, statusFilter), "]"] })] }), searchState.status === 'idle' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "Digite para buscar. Esc fecha a busca (o filtro de status \u00E9 o f com a busca fechada)." }) })) : null, searchState.status === 'loading' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Spinner, {}), " Buscando..."] }) })) : null, searchState.status === 'loaded' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [searchState.degraded ? (_jsxs(Text, { color: theme.warning, children: [symbols.warning, " ", searchState.warnings.join(' ')] })) : null, searchState.items.length === 0 ? (_jsx(Text, { color: theme.muted, children: "nenhuma issue encontrada" })) : (searchState.items.map((item, index) => (_jsx(SearchResultRow, { item: item, selected: index === searchIndex }, item.id))))] })) : null, searchState.status === 'error-network' || searchState.status === 'error-forbidden' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", searchState.message] }) })) : null, searchState.status === 'auth-aborted' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: searchState.message }) })) : null] })) : (_jsxs(_Fragment, { children: [state.status === 'loading' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Spinner, {}), " Carregando issues..."] }) })) : null, state.status === 'empty' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "nenhuma issue atribu\u00EDda" }) })) : null, state.status === 'error-network' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.danger, children: [symbols.cross, " Falha ao carregar suas issues: ", state.message] }), _jsxs(Text, { color: theme.muted, children: ["Pressione", ' ', _jsx(Text, { color: theme.accent, children: "r" }), ' ', "para tentar de novo."] })] })) : null, state.status === 'error-forbidden' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", state.message] }), _jsxs(Text, { color: theme.muted, children: ["Pressione", ' ', _jsx(Text, { color: theme.accent, children: "r" }), ' ', "para tentar de novo."] })] })) : null, state.status === 'auth-aborted' ? (
197
325
  // Fix do review #120: estado NEUTRO (abandono consciente do re-login,
198
326
  // Esc) — `theme.muted`, não `theme.danger` (não é uma falha).
199
- _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { color: theme.muted, children: state.message }) })) : null, state.status === 'loaded' ? (_jsx(Box, { marginTop: 1, flexDirection: "column", children: state.issues.map((issue, index) => (_jsx(IssueRow, { issue: issue, selected: index === selectedIndex }, issue.id))) })) : null] })), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: isSearching ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.accent, children: "Esc" }), ' ', "fecha a busca,", ' ', _jsx(Text, { color: theme.accent, children: "f" }), ' ', "cicla o filtro."] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.accent, children: `${glyphs.arrowUp}/${glyphs.arrowDown}` }), ' ', "navega,", ' ', _jsx(Text, { color: theme.accent, children: "Enter" }), ' ', "abre a issue,", ' ', _jsx(Text, { color: theme.accent, children: "/" }), ' ', "busca,", ' ', _jsx(Text, { color: theme.accent, children: "t" }), ' ', "jobs,", ' ', _jsx(Text, { color: theme.accent, children: "Esc" }), ' ', "volta."] })) }) })] }));
327
+ _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { color: theme.muted, children: state.message }) })) : null, state.status === 'loaded' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [state.issues.slice(listWindow_.start, listWindow_.end).map((issue, index) => (_jsx(IssueRow, { issue: issue, selected: listWindow_.start + index === selectedIndex }, issue.id))), state.issues.length > listWindow_.end - listWindow_.start ? (_jsxs(Text, { color: theme.muted, children: [` ${listWindow_.start + 1}-${listWindow_.end} de ${state.issues.length}`, listWindow_.start > 0 ? ` ${glyphs.arrowUp}` : '', listWindow_.end < state.issues.length ? ` ${glyphs.arrowDown}` : ''] })) : null] })) : null] })), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: isSearching ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.accent, children: "Esc" }), ' ', "fecha a busca,", ' ', _jsx(Text, { color: theme.accent, children: `${glyphs.arrowUp}/${glyphs.arrowDown}` }), " navega os resultados,", ' ', _jsx(Text, { color: theme.accent, children: "Enter" }), " abre. O filtro \u00E9 o", ' ', _jsx(Text, { color: theme.accent, children: "f" }), " com a busca fechada."] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.accent, children: `${glyphs.arrowUp}/${glyphs.arrowDown}` }), ' ', "navega,", ' ', _jsx(Text, { color: theme.accent, children: `${glyphs.arrowLeft}/${glyphs.arrowRight}` }), ' ', "p\u00E1gina,", ' ', _jsx(Text, { color: theme.accent, children: "Enter" }), ' ', "abre a issue,", ' ', _jsx(Text, { color: theme.accent, children: "/" }), ' ', "busca,", ' ', _jsx(Text, { color: theme.accent, children: "t" }), ' ', "jobs,", ' ', _jsx(Text, { color: theme.accent, children: "Esc" }), ' ', "volta."] })) }) })] }));
200
328
  }
@@ -47,23 +47,35 @@ import { useNavigation } from '../navigation.js';
47
47
  import { statusColor } from '../status-color.js';
48
48
  import { symbols } from '../symbols.js';
49
49
  import { useTheme } from '../theme.js';
50
+ import { truncate } from '../truncate.js';
50
51
  import { wrapText } from '../wrap.js';
51
52
  import { useHomeSelection } from './home-selection.js';
52
53
  import { useLoadedIssue } from './loaded-issue-context.js';
53
54
  /**
54
- * Overhead de linhas FORA da viewport de descrição (breadcrumb + título + meta +
55
- * rodapé + paddings). A viewport cresce com o terminal: `altura - OVERHEAD`
56
- * (#190). Com a altura default dos testes (24) dá 10 — o valor histórico — então
57
- * os snapshots/asserções de rolagem seguem iguais; em terminal real a descrição
58
- * 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.
59
62
  */
60
63
  const CONTENT_OVERHEAD_ROWS = 14;
61
64
  /** Piso da viewport (terminais muito baixos). */
62
65
  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;
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;
65
75
  /** Piso da largura de texto (terminais muito estreitos). */
66
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;
67
79
  /** Placeholder discreto para campos ausentes (assignee, autor/data de journal, old/new value). ASCII no Windows legado (#84). */
68
80
  const EMPTY_PLACEHOLDER = glyphs.emptyPlaceholder;
69
81
  /** Metadados fixos no topo: id/subject, status/prioridade, autor/responsável, datas. */
@@ -73,7 +85,7 @@ function IssueMeta({ issue, theme }) {
73
85
  /** Constrói a linha de um único anexo: nome (texto derivado, puro) · tamanho humanizado · content_type · badge de status. */
74
86
  function buildAttachmentRow(attachment, theme) {
75
87
  const status = deriveAttachmentExtractionStatus(attachment);
76
- 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}`));
77
89
  }
78
90
  /** Constrói as linhas da seção de anexos (após os journals): cabeçalho + 1 linha por anexo, ou "(nenhum)". */
79
91
  function buildAttachmentRows(issue, theme) {
@@ -102,7 +114,7 @@ function buildContentRows(issue, theme, width, lookups) {
102
114
  // linhas de tela, e um parágrafo longo (o caso comum de um chamado) faria o
103
115
  // Ink quebrá-lo sozinho em várias, estourando o viewport.
104
116
  wrapText(issue.description, width).forEach((line, index) => {
105
- rows.push(_jsx(Text, { children: line.length > 0 ? line : ' ' }, `desc-${index}`));
117
+ rows.push(_jsx(Text, { wrap: "truncate", children: line.length > 0 ? line : ' ' }, `desc-${index}`));
106
118
  });
107
119
  }
108
120
  rows.push(_jsx(Text, { children: " " }, "desc-spacer"));
@@ -113,10 +125,10 @@ function buildContentRows(issue, theme, width, lookups) {
113
125
  else {
114
126
  // Cronológico: preserva a ordem já entregue por `getIssue`/`normalizeIssue`.
115
127
  issue.journals.forEach((journal) => {
116
- 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`));
117
129
  if (journal.notes !== undefined && journal.notes !== '') {
118
130
  wrapText(journal.notes, width).forEach((line, index) => {
119
- rows.push(_jsx(Text, { children: line }, `journal-${journal.id}-note-${index}`));
131
+ rows.push(_jsx(Text, { wrap: "truncate", children: line }, `journal-${journal.id}-note-${index}`));
120
132
  });
121
133
  }
122
134
  // Details resumidos: `campo: antigo → novo`, sempre em muted. Rótulos e
@@ -127,9 +139,15 @@ function buildContentRows(issue, theme, width, lookups) {
127
139
  // interface, não prompt.
128
140
  journal.details.forEach((detail, index) => {
129
141
  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}`));
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}`));
133
151
  });
134
152
  rows.push(_jsx(Text, { children: " " }, `journal-${journal.id}-spacer`));
135
153
  });
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redmine-context",
3
- "version": "1.1.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",