@lamemind/loom-deck 0.33.0 → 0.34.1

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/dist/glyphs.js ADDED
@@ -0,0 +1,83 @@
1
+ // Glifi letterali del frame e formatter di display. Fase VISTA, ma senza JSX:
2
+ // tutto qui è puro e testabile, e nessun sito di render scrive un glifo nudo.
3
+ import { sanitize } from './width.js';
4
+ // Glifi LETTERALI del JSX. I dati passano dai loader, che sanificano al
5
+ // confine; questi no — quindi passano da `sanitize` una volta qui, così nessun
6
+ // sito di render scrive un glifo nudo. `↳ ○ ▸ ⏎ · − ↑ ↓` sono già concordi e
7
+ // restano intatti; `▶` e `⚠` sono discordi e vengono sostituiti (`width.ts`).
8
+ export const CARET = sanitize('▶ ');
9
+ export const CARET_OFF = ' ';
10
+ export const WARN = sanitize('⚠');
11
+ // T50 — separatore leggero fra blocco pinnate e contestuali. `─` (box-drawing) è
12
+ // largo 1 sia per string-width sia per il terminale. Corto +
13
+ // wrap="truncate-end" così non va mai a capo nel pane al 50%.
14
+ export const SESSION_SEP = '─'.repeat(16);
15
+ // Prefisso del sessionId mostrato in lista: stesso dato e stessa lunghezza del
16
+ // widget `⛓ <8 char>` della statusline, così le due superfici si confrontano a
17
+ // occhio.
18
+ export const SID_CHARS = 8;
19
+ /** T60 — segnaposto della colonna task su una riga senza binding. Una cella
20
+ * vuota di soli spazi lascerebbe un buco che si legge come "colonna finita",
21
+ * e la riga tornerebbe a sembrare disallineata pur non essendolo. */
22
+ export const TASK_EMPTY = '·';
23
+ // T62 — colonna liveness, larga 1, incollata al sessionId senza gutter proprio:
24
+ // il glifo qualifica QUELL'id, e uno spazio in mezzo lo farebbe leggere come una
25
+ // colonna a sé. Entrambi Ambiguous (EAW) → 1 colonna per il terminale e 1 cella
26
+ // per Ink, quindi concordi (invariante ① di width.ts): il pieno/vuoto del
27
+ // cerchio è l'unico asse che varia, e resta scandibile in verticale.
28
+ //
29
+ // Sulla riga CHIUSA c'è uno spazio e non un terzo glifo: le chiuse sono la
30
+ // maggioranza di ogni lista, e marcarle vorrebbe dire disegnare N volte «niente
31
+ // da dire» — la colonna diventerebbe rumore invece di un segnale.
32
+ export const LIVE_IDLE = '●';
33
+ export const LIVE_BUSY = '◍';
34
+ export const LIVE_NONE = ' ';
35
+ // Marker Done per il DISPLAY. `task.prog` resta il `✔️` letto da tasks.md —
36
+ // `isDone()` e le lookup di `view.ts` ci confrontano sopra, e `task-edit` lo
37
+ // riscrive sul file: è una chiave semantica, non testo. Qui `sanitize` lo
38
+ // traduce nel suo gemello concorde (`✅`) solo per finire nel frame.
39
+ export function displayProg(prog) {
40
+ return sanitize(prog);
41
+ }
42
+ // T49 — size umana compatta per il detail pane sessione.
43
+ export function fmtSize(bytes) {
44
+ if (bytes < 1024)
45
+ return `${bytes} B`;
46
+ if (bytes < 1024 * 1024)
47
+ return `${(bytes / 1024).toFixed(1)} KB`;
48
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
49
+ }
50
+ // T49 — ultima attività ESTESA (giorno/mese ora:minuti) per il detail pane;
51
+ // nella riga di lista resta il relTime compatto.
52
+ export function fmtDateTime(ts) {
53
+ const d = new Date(ts);
54
+ const p = (n) => String(n).padStart(2, '0');
55
+ return `${p(d.getDate())}/${p(d.getMonth() + 1)} ${p(d.getHours())}:${p(d.getMinutes())}`;
56
+ }
57
+ // Età relativa compatta (ms epoch → "2m"/"3h"/"5d") per il preview sessioni.
58
+ export function relTime(ts) {
59
+ const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
60
+ if (sec < 60)
61
+ return `${sec}s`;
62
+ const min = Math.floor(sec / 60);
63
+ if (min < 60)
64
+ return `${min}m`;
65
+ const hr = Math.floor(min / 60);
66
+ if (hr < 24)
67
+ return `${hr}h`;
68
+ return `${Math.floor(hr / 24)}d`;
69
+ }
70
+ /**
71
+ * Ripulisce un chunk di stdin prima di scriverlo in un campo di testo.
72
+ *
73
+ * `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio — o
74
+ * una raffica piu' veloce di una read — arriva come stringa unica, byte di
75
+ * controllo compresi. Non si vedono a schermo, ma Ink li conta nella larghezza
76
+ * della riga, e in un campo che finisce su disco (la nota, T53) resterebbero
77
+ * li' per sempre. Le newline diventano SPAZIO invece di sparire: incollare due
78
+ * righe deve separare le parole, non fonderle.
79
+ */
80
+ export function sanitizeTyped(s) {
81
+ return s.replace(/[\r\n]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
82
+ }
83
+ export const META_KEYS = ['Priority', 'Size', 'Estimated Time', 'Progress'];
package/dist/hooks.js ADDED
@@ -0,0 +1,192 @@
1
+ // Fase ACQUISIZIONE DATI: i cinque hook che portano dentro il componente ciò
2
+ // che sta fuori — dimensioni del terminale, tasks.md, sessioni del progetto,
3
+ // task archiviabili, task file selezionato. Ognuno possiede la propria cadenza
4
+ // di refresh e non sa nulla della vista che li consuma.
5
+ import { useState, useEffect, useRef } from 'react';
6
+ import { useStdout } from 'ink';
7
+ import { statSync } from 'node:fs';
8
+ import { loadTasks, loadTaskDetail } from './tasks.js';
9
+ import { discoverProjectSessions } from './sessions.js';
10
+ import { discoverLiveSessions, liveSig } from './live-sessions.js';
11
+ import { loadSessionIndex } from './task-index.js';
12
+ import { archivableIds, SCAN_INTERVAL_MS } from './archivable.js';
13
+ import { POLL_MS } from './model.js';
14
+ // Dimensioni del terminale, live sul resize.
15
+ //
16
+ // Non è una comodità di layout: senza `rows` il frame non ha tetto, e un frame
17
+ // più alto del terminale fa cadere Ink nel ramo `clearTerminal` (ink.js:121)
18
+ // che su VTE/Ptyxis riversa ogni redraw nello scrollback.
19
+ //
20
+ // Il valore iniziale conta quanto il resize: una tab Ptyxis appena aperta parte
21
+ // spesso a 24 righe e riceve il SIGWINCH subito dopo. Nella finestra fra i due
22
+ // il deck disegnava già a piena altezza — motivo per cui lo scrollback risultava
23
+ // sporco fin dall'avvio, prima ancora di toccare un tasto.
24
+ export function useTerminalSize() {
25
+ const { stdout } = useStdout();
26
+ const [size, setSize] = useState({ rows: stdout.rows || 24, columns: stdout.columns || 80 });
27
+ useEffect(() => {
28
+ const onResize = () => setSize({ rows: stdout.rows || 24, columns: stdout.columns || 80 });
29
+ stdout.on('resize', onResize);
30
+ onResize(); // allinea se il resize è arrivato prima del mount
31
+ return () => {
32
+ stdout.off('resize', onResize);
33
+ };
34
+ }, [stdout]);
35
+ return size;
36
+ }
37
+ // Carica tasks.md e lo ri-legge quando cambia sotto (poll su mtime). Poll
38
+ // (non fs.watch) perché i writer di tasks.md — checkpoint-task/create-task —
39
+ // riscrivono il file (probabile replace atomico), che rompe il watch sull'inode
40
+ // originale; statSync(path) segue sempre il file corrente al path.
41
+ export function useTasks(tasksPath) {
42
+ const [tasks, setTasks] = useState([]);
43
+ const [loadError, setLoadError] = useState('');
44
+ useEffect(() => {
45
+ let lastMtime = -1;
46
+ const reload = () => {
47
+ try {
48
+ const mtime = statSync(tasksPath).mtimeMs;
49
+ if (mtime === lastMtime)
50
+ return; // invariato → niente re-read
51
+ lastMtime = mtime;
52
+ setTasks(loadTasks(tasksPath));
53
+ setLoadError('');
54
+ }
55
+ catch {
56
+ lastMtime = -1; // così quando il file riappare viene ri-letto
57
+ setTasks([]);
58
+ setLoadError(`tasks.md non leggibile: ${tasksPath}`);
59
+ }
60
+ };
61
+ reload();
62
+ const id = setInterval(reload, POLL_MS);
63
+ return () => clearInterval(id);
64
+ }, [tasksPath]);
65
+ return { tasks, loadError };
66
+ }
67
+ // Poll delle sessioni del progetto + binding sidecar. discoverProjectSessions
68
+ // ha cache mtime-keyed interna → il poll è economico; qui si evita comunque il
69
+ // re-render inutile con una signature (sessionId:ts + binding entries): setState
70
+ // solo quando cambia davvero qualcosa.
71
+ export function useSessions(projectRoot) {
72
+ const [state, setState] = useState({
73
+ sessions: [],
74
+ bindings: new Map(),
75
+ forkOf: new Map(),
76
+ pinned: new Map(),
77
+ notes: new Map(),
78
+ live: new Map(),
79
+ });
80
+ // T50 — pin/unpin scrive il sidecar e vuole feedback IMMEDIATO, non al
81
+ // prossimo tick del poll (1.5s): la reload è esposta via ref così il toggle la
82
+ // richiama senza risottoscrivere l'intervallo.
83
+ const reloadRef = useRef(() => { });
84
+ useEffect(() => {
85
+ let lastSig = '';
86
+ const reload = () => {
87
+ let sessions;
88
+ let index;
89
+ try {
90
+ sessions = discoverProjectSessions(projectRoot);
91
+ index = loadSessionIndex(projectRoot);
92
+ }
93
+ catch {
94
+ sessions = [];
95
+ index = { bindings: new Map(), forkOf: new Map(), pinned: new Map(), notes: new Map() };
96
+ }
97
+ // T62 — le vive stanno sullo STESSO tick delle altre fonti, non su una
98
+ // scala propria come `useArchivable`: `status` cambia a ogni turno, quindi
99
+ // un refresh più lento mostrerebbe `idle` su una sessione che lavora.
100
+ // Il try è separato perché il registry è una fonte indipendente: se manca
101
+ // (versione del CLI che non lo scrive) la lista deve restare, senza vive.
102
+ let live;
103
+ try {
104
+ live = discoverLiveSessions(projectRoot);
105
+ }
106
+ catch {
107
+ live = new Map();
108
+ }
109
+ const { bindings, forkOf, pinned, notes } = index;
110
+ // La signature copre anche fork, pin e note: un record di lineage, un
111
+ // toggle di pin o una nota appena scritta cambiano la lista renderizzata,
112
+ // quindi devono forzare il re-render come farebbe un binding nuovo.
113
+ const sig = sessions.map((s) => `${s.sessionId}:${s.ts}`).join('|') +
114
+ '#' +
115
+ [...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',') +
116
+ '#' +
117
+ [...forkOf.entries()].map(([k, v]) => `${k}<${v}`).sort().join(',') +
118
+ '#' +
119
+ [...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',') +
120
+ '#' +
121
+ [...notes.entries()].map(([k, v]) => `${k}"${v}`).sort().join(',') +
122
+ '#' +
123
+ liveSig(live);
124
+ if (sig === lastSig)
125
+ return;
126
+ lastSig = sig;
127
+ setState({ sessions, bindings, forkOf, pinned, notes, live });
128
+ };
129
+ reloadRef.current = reload;
130
+ reload();
131
+ const id = setInterval(reload, POLL_MS);
132
+ return () => clearInterval(id);
133
+ }, [projectRoot]);
134
+ return { ...state, reload: () => reloadRef.current() };
135
+ }
136
+ // T61 — conteggio delle Done oltre soglia, su una scala di refresh TUTTA SUA.
137
+ //
138
+ // Non è appeso a POLL_MS (1,5s) come tasks.md e le sessioni: l'età di una task
139
+ // cambia una volta al giorno, e ogni giro costa la lettura di N task file più
140
+ // qualche spawn di git. Due trigger:
141
+ //
142
+ // · quando cambia l'INSIEME delle task Done (`doneSig`) — copre l'avvio, dove
143
+ // il primo render ha `tasks` ancora vuoto, e la chiusura di una task, dove
144
+ // il numero deve muoversi senza aspettare ore;
145
+ // · ogni SCAN_INTERVAL_MS — copre il caso opposto, in cui non cambia nulla
146
+ // sul disco ed è il calendario a far scattare una task oltre soglia.
147
+ //
148
+ // `doneSig` è una stringa, non l'array: `tasks` cambia identità a ogni re-read
149
+ // di tasks.md, e usarlo come dipendenza rimetterebbe lo scan sul tick da 1,5s
150
+ // per la via di dietro.
151
+ //
152
+ // T100 — tiene gli ID e non più il conteggio: `archiviabili` è una vista del
153
+ // pane, quindi servono le righe. Il contatore dell'header è `.size` dello stesso
154
+ // insieme che disegna la lista — un numero e una lista che non possono divergere.
155
+ export function useArchivable(doneSig, tasksDir, projectRoot, days) {
156
+ const [ids, setIds] = useState(() => new Set());
157
+ useEffect(() => {
158
+ let alive = true;
159
+ const done = doneSig ? doneSig.split(',') : [];
160
+ const scan = () => {
161
+ archivableIds(done, { tasksDir, projectRoot, days })
162
+ .then((found) => {
163
+ if (alive)
164
+ setIds(new Set(found));
165
+ })
166
+ // Scan fallito (task file illeggibili, git muto) → insieme vuoto, cioè
167
+ // voce a 0. Un contatore informativo non merita un errore a schermo.
168
+ .catch(() => {
169
+ if (alive)
170
+ setIds(new Set());
171
+ });
172
+ };
173
+ scan();
174
+ const id = setInterval(scan, SCAN_INTERVAL_MS);
175
+ return () => {
176
+ alive = false;
177
+ clearInterval(id);
178
+ };
179
+ }, [doneSig, tasksDir, projectRoot, days]);
180
+ return ids;
181
+ }
182
+ // Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
183
+ // con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
184
+ // niente debounce serve per la tastiera. Il refresh del contenuto a file fermo
185
+ // (es. checkpoint aggiorna Progress) è demandato al prossimo cambio selezione.
186
+ export function useTaskDetail(tasksDir, id) {
187
+ const [detail, setDetail] = useState(null);
188
+ useEffect(() => {
189
+ setDetail(id ? loadTaskDetail(tasksDir, id) : null);
190
+ }, [tasksDir, id]);
191
+ return detail;
192
+ }
@@ -0,0 +1,52 @@
1
+ // La PRECEDENZA fra i modi dell'handler input, dichiarata come dato.
2
+ //
3
+ // Prima di questo file la precedenza era l'ordine testuale di dieci `if` dentro
4
+ // un `useInput` da 850 righe: un'informazione portante che nessun tipo esprimeva
5
+ // e nessun test fissava. Ha già prodotto una correzione non ovvia — il ramo
6
+ // `detail` anteposto a quello `key.ctrl` per rendere inerti gli acceleratori
7
+ // dentro il detail, con `^F` come deroga *dentro* quel ramo.
8
+ //
9
+ // Il custode è `MODE_KEYS` in `cli.tsx`: un `Record<CapturingMode, Handler>`
10
+ // che non compila se un modo nuovo resta senza handler. Il test gemello fissa
11
+ // l'insieme e la deroga.
12
+ /**
13
+ * I modi che CATTURANO l'input per intero.
14
+ *
15
+ * Mentre uno di questi è attivo sono inerti: gli acceleratori globali
16
+ * (`^K`/`^P`/`^R`/`^F`), i tasti nudi della lista, `tab`, `←→` e `q`. Il modo
17
+ * risponde di ogni tasto che riceve — anche solo per ignorarlo.
18
+ *
19
+ * `normal` è l'unico modo NON capturing, ed è il complemento esatto di questo
20
+ * insieme: è lì che vivono gli acceleratori.
21
+ */
22
+ export const CAPTURING_MODES = [
23
+ 'detail',
24
+ 'reader',
25
+ 'search',
26
+ 'assign',
27
+ 'create',
28
+ 'note',
29
+ 'sort',
30
+ 'filter',
31
+ 'edit',
32
+ ];
33
+ const _MODES_ARE_EXHAUSTIVE = true;
34
+ void _MODES_ARE_EXHAUSTIVE;
35
+ const CAPTURING = new Set(CAPTURING_MODES);
36
+ /** `true` se il modo intercetta l'input prima degli acceleratori globali. */
37
+ export function captures(mode) {
38
+ return CAPTURING.has(mode);
39
+ }
40
+ /**
41
+ * Le DEROGHE: combinazioni `ctrl` che restano vive dentro un modo capturing,
42
+ * con un significato proprio.
43
+ *
44
+ * Oggi ce n'è una sola — `^F` dentro `detail`, che apre la ricerca nel testo
45
+ * invece di quella sulle conversazioni. Non è l'acceleratore globale che
46
+ * sopravvive: è un tasto diverso che porta lo stesso nome, e il modo se lo
47
+ * gestisce da sé. Sta scritto qui perché è l'eccezione a una regola dichiarata,
48
+ * e un'eccezione che vive solo dentro un `if` annidato è come non averla.
49
+ */
50
+ export const CTRL_DEROGATIONS = {
51
+ detail: ['f'],
52
+ };
package/dist/layout.js ADDED
@@ -0,0 +1,121 @@
1
+ // Funzioni pure di LARGHEZZA e di manipolazione testo che i componenti di vista
2
+ // consumano. Stanno insieme perché condividono un'unica invariante: la cornice
3
+ // da scalare va tolta in un posto solo, o due chiamanti tolgono cose diverse
4
+ // dalla stessa larghezza e il bordo viene mangiato.
5
+ import { stripProjectCore } from './session-list.js';
6
+ export const EDIT_ROWS = 4;
7
+ /** Le righe del modale edit che sono campi di TESTO (il resto è scelta ←→). */
8
+ export function isTextRow(r) {
9
+ return r === 2 || r === 3;
10
+ }
11
+ /** Chiave della bozza scritta dalla riga di testo `r`. */
12
+ export function editField(r) {
13
+ return r === 2 ? 'detail' : 'title';
14
+ }
15
+ /**
16
+ * Lunghezza in CODE POINT. Il caret ci indicizza sopra: `.length` conterebbe le
17
+ * code unit UTF-16 e un'emoji nel titolo varrebbe 2 posizioni, cioè un cursore
18
+ * che si ferma a metà glifo e un `slice` che lo spezza in due surrogati.
19
+ */
20
+ export function cpLen(s) {
21
+ return [...s].length;
22
+ }
23
+ /** Inserisce `ins` alla posizione `at` (code point). */
24
+ export function insertAt(s, at, ins) {
25
+ const cp = [...s];
26
+ return cp.slice(0, at).join('') + ins + cp.slice(at).join('');
27
+ }
28
+ /** Toglie il code point in posizione `at`; fuori range = stringa invariata. */
29
+ export function removeAt(s, at) {
30
+ const cp = [...s];
31
+ if (at < 0 || at >= cp.length)
32
+ return s;
33
+ cp.splice(at, 1);
34
+ return cp.join('');
35
+ }
36
+ export function isDone(prog) {
37
+ return prog.includes('✔');
38
+ }
39
+ /**
40
+ * Larghezza dell'estratto, DERIVATA dalle colonne del terminale.
41
+ *
42
+ * Una costante qui è spazio buttato a ogni riga: su un terminale a 190 colonne
43
+ * un estratto fisso a 50 lascia il match con ~20 caratteri di contesto per lato
44
+ * quando potrebbe averne 80 — e il contesto attorno al match è l'unica ragione
45
+ * per cui si legge la riga invece di aprire il reader.
46
+ *
47
+ * Scomposizione delle colonne consumate dalla cornice e dal prefisso di riga:
48
+ * 4 box esterno (2 bordi + 2 padding)
49
+ * 4 box lista (2 bordi + 2 padding)
50
+ * 2 caret
51
+ * 4 indice del record
52
+ * 4 spazio + tag del kind (2 caratteri) + spazio
53
+ * Prudente per costruzione: sottostimare tronca un carattere in più,
54
+ * sovrastimare manderebbe la riga a capo e sfonderebbe il budget d'altezza.
55
+ */
56
+ export function searchExcerptWidth(columns) {
57
+ return Math.max(30, (columns || 80) - 18);
58
+ }
59
+ /** Titolo della conversazione sulla riga-gruppo: prende ciò che avanza dopo le
60
+ * colonne a larghezza fissa (caret, pin, hash, task, conteggio, data). */
61
+ export function searchTitleWidth(columns) {
62
+ return Math.max(24, (columns || 80) - 56);
63
+ }
64
+ /**
65
+ * Cosa scrivere sulla riga-gruppo per distinguere una conversazione dall'altra.
66
+ *
67
+ * Non basta `session.title`: quel titolo è la label della tab Ptyxis, cioè
68
+ * `<emoji> <name>` più un eventuale suffisso. Su una lista tutta dello
69
+ * stesso progetto (D3) è una COLONNA COSTANTE — tre righe su quattro
70
+ * identiche, che è esattamente il difetto che la riga-gruppo doveva evitare.
71
+ *
72
+ * Si toglie quindi il core `<name>` con tutto ciò che lo precede (noto dal file
73
+ * config), e se ciò
74
+ * che resta è vuoto o duplica la task già mostrata nella sua colonna, si
75
+ * ripiega sul primo prompt — l'unica cosa che davvero identifica quella
76
+ * conversazione e non un'altra.
77
+ */
78
+ export function conversationLabel(s, core, bound) {
79
+ let t = stripProjectCore(s.title, core);
80
+ if (bound && t === bound)
81
+ t = '';
82
+ return t || s.firstPrompt || '(senza titolo)';
83
+ }
84
+ /**
85
+ * Larghezza del testo dentro la lista della schermata di assegnazione: box
86
+ * esterno (2 bordi + 2 padding) + box lista (2 bordi + 2 padding).
87
+ *
88
+ * Invariante ③ (`width.ts`): il taglio lo fa il chiamante. Delegarlo a
89
+ * `wrap="truncate-end"` passerebbe da `cli-truncate`, che restituisce una riga
90
+ * più larga di quella chiesta di una colonna per emoji — e quelle colonne
91
+ * finiscono sopra il bordo, che sparisce dalla riga.
92
+ */
93
+ export function assignTextWidth(columns) {
94
+ return Math.max(20, (columns || 80) - 8);
95
+ }
96
+ /**
97
+ * T70 — larghezza utile del testo dentro il blocco preview, che è a PIENA
98
+ * larghezza: box esterno (2 bordi + 2 padding) → box preview (2 bordi + 2
99
+ * padding). Niente più `/2`: il blocco non vive più dentro un pane al 50%,
100
+ * quindi una riga di descrizione dispone del doppio delle colonne.
101
+ *
102
+ * Volutamente prudente: sottostimare tronca qualche carattere in più,
103
+ * sovrastimare farebbe andare a capo una riga e sforare il tetto d'altezza.
104
+ */
105
+ export function previewTextWidth(columns) {
106
+ return Math.max(10, (columns || 80) - 8);
107
+ }
108
+ /**
109
+ * Larghezza del TESTO dentro un pane al 50%: box esterno (2 bordi + 2 padding)
110
+ * → metà → bordo + padding del pane.
111
+ *
112
+ * Invariante ③ (`width.ts`): chi renderizza una riga a lunghezza libera la
113
+ * taglia PRIMA con questa larghezza. Delegarlo a `wrap="truncate-end"`
114
+ * significa passare da `cli-truncate`, che indicizza per code point e restituisce
115
+ * una riga più larga di quella chiesta — una colonna per ogni emoji astrale a
116
+ * sinistra del taglio. Quelle colonne finiscono sopra il bordo del pane, che
117
+ * sparisce dalla riga: è la sminchiatura visibile a schermo.
118
+ */
119
+ export function paneTextWidth(columns) {
120
+ return Math.max(20, Math.floor(((columns || 80) - 4) / 2) - 4);
121
+ }
package/dist/model.js ADDED
@@ -0,0 +1,55 @@
1
+ export const POLL_MS = 1500;
2
+ // Cap del pane sessioni: le più recenti (ts desc), le altre restano nell'indice
3
+ // ma fuori vista. Non-silenzioso → l'header mostra quante sono nascoste.
4
+ export const MAX_SESSIONS = 30;
5
+ // T59 D3 — cap dedicato alla vista "tutte": a 30 su un progetto con ~170
6
+ // conversazioni la lista sarebbe esaustiva solo sull'ultimo 17%, cioè
7
+ // contraddirebbe lo scopo della riga. 100 copre la finestra temporale utile e
8
+ // tiene comunque un tetto alle righe da attraversare con ↑↓.
9
+ export const MAX_SESSIONS_ALL = 100;
10
+ // Modello task-centrico: il Tasks pane ha, oltre alle task reali, DUE righe
11
+ // meta in testa — `≡ tutte` (ogni conversazione del progetto) e `○ spot` (le
12
+ // sole NON legate ad alcuna task). La selezione nel Tasks pane è il "padre"; il
13
+ // Sessions pane mostra i suoi figli.
14
+ //
15
+ // T59 D1 — le sentinelle sono Symbol, non `null` né stringhe riservate: la
16
+ // selezione è un ENUM a tre casi (task / spot / tutte), non un flag. Un Symbol
17
+ // non può collidere con un task id e si confronta secco (`sel === ALL`); una
18
+ // stringa sentinella farebbe invece circolare un id-fantasma dentro un tipo che
19
+ // altrove significa "task id".
20
+ export const SPOT = Symbol('spot');
21
+ export const ALL = Symbol('all');
22
+ // Le righe meta occupano le prime posizioni della lista: l'indice di una task
23
+ // nella VISTA è quindi il suo indice in `viewTasks` + META_ROWS.
24
+ export const ROW_ALL = 0;
25
+ export const ROW_SPOT = 1;
26
+ export const META_ROWS = 2;
27
+ // Modale sort a grammatica libera: un tasto per chiave, pressioni successive
28
+ // ciclano asc → desc → fuori dalla chain.
29
+ export const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
30
+ // T52 — toggle del modale ricerca, tutti su CTRL (D2).
31
+ //
32
+ // I due campi di testo mangiano ogni lettera nuda, quindi un toggle non può
33
+ // essere una lettera semplice: resterebbero i caratteri della query. CTRL è
34
+ // l'unico livello che convive con la digitazione senza modi né navigazione.
35
+ //
36
+ // Le mnemoniche ovvie sono precluse dall'ASCII, non da una scelta di design:
37
+ // `^I` È il Tab (0x09) e `^H` È il Backspace (0x08) — stesso byte, nessuna
38
+ // distinzione possibile a valle. Quindi niente I=IA e niente H=human. Bruciati
39
+ // per lo stesso motivo `^M` (Enter), `^J` (LF), `^[` (Esc). Tutto il resto passa
40
+ // pulito, `^S`/`^Q` inclusi: il raw mode di Ink disattiva il flow-control XON/XOFF
41
+ // che altrimenti se li mangerebbe il terminale.
42
+ export const SEARCH_TOGGLE_KEYS = {
43
+ r: 'regex',
44
+ a: 'caseSensitive',
45
+ w: 'wholeWord',
46
+ };
47
+ export const SEARCH_KIND_KEYS = { b: 'ai', t: 'tool', u: 'human' };
48
+ export const KIND_LABEL = { ai: 'IA', tool: 'tools', human: 'human' };
49
+ // T41 — ordine dei valori nel modale edit. Deliberatamente DIVERSO da
50
+ // PRI_ENTRIES/PROG_ENTRIES (che seguono il rango di sort): qui si sceglie un
51
+ // valore, non si ordina, quindi vince l'ordine del CICLO DI VITA — da fare →
52
+ // in corso → chiusa → bloccata. La priorità resta alta→bassa, che è già
53
+ // l'ordine naturale di lettura.
54
+ export const EDIT_PRI = ['high', 'med', 'low'];
55
+ export const EDIT_PROG = ['todo', 'wip', 'done', 'locked'];
@@ -0,0 +1,112 @@
1
+ // Overlay ASSEGNAZIONE conversazione → task (T57): stato, lista filtrata e input.
2
+ //
3
+ // `sid` è la conversazione in assegnazione, FOTOGRAFATA all'apertura e non
4
+ // riletta dalla selezione corrente: il modale è fullscreen (D3), quindi il pane
5
+ // sessioni non è più a schermo e l'oggetto dell'azione deve restare quello che
6
+ // si è scelto — anche se un tick del poll rimescolasse la lista sotto.
7
+ //
8
+ // `sel` è la task di destinazione: `null` è la riga `detach` (D2), che sta
9
+ // sempre in testa e scrive un binding vuoto.
10
+ //
11
+ // La scrittura del sidecar NON sta qui: arriva come callback `onSubmit`.
12
+ import { useEffect, useMemo, useState } from 'react';
13
+ import { assignListCapacity } from '../viewport.js';
14
+ import { sanitizeTyped } from '../glyphs.js';
15
+ export function useAssignOverlay(deps) {
16
+ const { viewTasks, rows, hasNote, setMode, setNote, onSubmit } = deps;
17
+ const [sid, setSid] = useState(null);
18
+ const [filter, setFilter] = useState('');
19
+ const [sel, setSel] = useState(null);
20
+ // Il filtro è un substring case-insensitive su id + titolo grezzo (D5): un
21
+ // restringimento veloce, non una ricerca — quella è `^F` e ha il suo motore.
22
+ // Il solo id non basterebbe, perché il caso d'uso nasce proprio dal «ho
23
+ // capito ora che questa conversazione è la task del titolo X».
24
+ //
25
+ // `detach` NON si filtra: è l'azione di svuotamento, non una task, e sparire
26
+ // digitando la renderebbe raggiungibile solo a campo vuoto.
27
+ const list = useMemo(() => {
28
+ const q = filter.trim().toLowerCase();
29
+ const matched = q
30
+ ? viewTasks.filter((t) => t.id.toLowerCase().includes(q) || t.rawDesc.toLowerCase().includes(q))
31
+ : viewTasks;
32
+ return [null, ...matched];
33
+ }, [viewTasks, filter]);
34
+ const capacity = assignListCapacity(rows, hasNote);
35
+ // T57 — la lista del modale si restringe a ogni carattere digitato: quando la
36
+ // task selezionata esce dal filtro si cade sulla PRIMA TASK, non su `detach`.
37
+ // Cadere su detach significherebbe che un `⏎` battuto di slancio dopo aver
38
+ // digitato staccherebbe la sessione invece di assegnarla — l'esatto opposto
39
+ // dell'intenzione. Senza match la selezione resta detach: è l'unica riga viva.
40
+ useEffect(() => {
41
+ if (sel !== null && !list.some((t) => t?.id === sel)) {
42
+ setSel(list[1]?.id ?? null);
43
+ }
44
+ }, [list, sel]);
45
+ // T57 — apertura. La preselezione è la task a cui la sessione è GIÀ legata
46
+ // (riassegnare è quasi sempre correggere, e vedere da dove si parte è metà
47
+ // dell'informazione), altrimenti la prima della vista. Mai `detach`: è
48
+ // l'azione distruttiva della lista, e preselezionarla metterebbe un `⏎`
49
+ // battuto di slancio a un tasto dal cancellare un binding.
50
+ function open(sessionId, bound) {
51
+ setSid(sessionId);
52
+ setFilter('');
53
+ setSel(bound && viewTasks.some((t) => t.id === bound) ? bound : viewTasks[0]?.id ?? null);
54
+ setNote('');
55
+ setMode('assign');
56
+ }
57
+ /** Sposta la selezione sulle righe (0 = detach, 1..N = task filtrate). */
58
+ function move(delta) {
59
+ const at = list.findIndex((t) => (t?.id ?? null) === sel);
60
+ const next = Math.max(0, Math.min(list.length - 1, (at < 0 ? 0 : at) + delta));
61
+ setSel(list[next]?.id ?? null);
62
+ }
63
+ function submit() {
64
+ const sessionId = sid;
65
+ const target = sel;
66
+ setMode('normal');
67
+ setFilter('');
68
+ if (!sessionId)
69
+ return;
70
+ onSubmit(sessionId, target);
71
+ }
72
+ // T57 — un campo di filtro più una lista. Come ricerca e nota, il campo mangia
73
+ // ogni lettera nuda → `^U` (kill-line delle shell) per svuotarlo, e la
74
+ // navigazione passa dalle frecce.
75
+ function onKey(input, key) {
76
+ if (key.escape) {
77
+ setMode('normal');
78
+ setFilter('');
79
+ setNote('A → assegnazione annullata');
80
+ }
81
+ else if (key.return) {
82
+ submit();
83
+ }
84
+ else if (key.upArrow) {
85
+ move(-1);
86
+ }
87
+ else if (key.downArrow) {
88
+ move(1);
89
+ }
90
+ else if (key.pageUp) {
91
+ move(-Math.max(1, capacity));
92
+ }
93
+ else if (key.pageDown) {
94
+ move(Math.max(1, capacity));
95
+ }
96
+ else if (key.ctrl) {
97
+ // `^U` svuota; ogni altra combo è no-op. Il backspace tenuto premuto
98
+ // cancella un carattere per CHUNK letto da stdin, non per pressione
99
+ // (T53): senza `^U` ripulire un filtro digitato di getto sarebbe lento
100
+ // quanto riaprire il modale.
101
+ if (input === 'u')
102
+ setFilter('');
103
+ }
104
+ else if (key.backspace || key.delete) {
105
+ setFilter((f) => f.slice(0, -1));
106
+ }
107
+ else if (input && !key.meta) {
108
+ setFilter((f) => f + sanitizeTyped(input));
109
+ }
110
+ }
111
+ return { sid, filter, sel, list, capacity, open, onKey };
112
+ }