@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/README.md +4 -4
- package/dist/cli.js +321 -1981
- package/dist/glyphs.js +83 -0
- package/dist/hooks.js +192 -0
- package/dist/input-modes.js +52 -0
- package/dist/layout.js +121 -0
- package/dist/model.js +55 -0
- package/dist/overlays/assign.js +112 -0
- package/dist/overlays/search.js +259 -0
- package/dist/overlays/sheet.js +222 -0
- package/dist/pane-views.js +1 -1
- package/dist/spawn.js +250 -0
- package/dist/ui/assign-screen.js +40 -0
- package/dist/ui/detail-screen.js +97 -0
- package/dist/ui/modals.js +59 -0
- package/dist/ui/panes.js +220 -0
- package/dist/ui/preview.js +45 -0
- package/dist/ui/search-screen.js +115 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,598 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
3
|
-
import { render, Box, Text, useApp, useInput
|
|
4
|
-
import { useState, useEffect, useMemo
|
|
5
|
-
import { spawn } from 'node:child_process';
|
|
6
|
-
import { EventEmitter } from 'node:events';
|
|
7
|
-
import { statSync } from 'node:fs';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { render, Box, Text, useApp, useInput } from 'ink';
|
|
4
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
8
5
|
import { randomUUID } from 'node:crypto';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { discoverLiveSessions, liveSig } from './live-sessions.js';
|
|
14
|
-
import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions, selectedRow, DEFAULT_OPTIONS, MIN_QUERY, } from './search.js';
|
|
15
|
-
import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
16
|
-
import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, rowLabel, selectedSession, sessionTitle, stripProjectCore, unpinLandingId, } from './session-list.js';
|
|
6
|
+
import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskFileText, } from './tasks.js';
|
|
7
|
+
import { rowIndexOfKey, selectedRow, } from './search.js';
|
|
8
|
+
import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, } from './task-index.js';
|
|
9
|
+
import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, selectedSession, unpinLandingId, } from './session-list.js';
|
|
17
10
|
import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import { caretWindow, cut, cutParts, pad, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
|
|
22
|
-
import { scanText, sliceLine, topForOffset, } from './text-search.js';
|
|
23
|
-
import { parseMarkdown, sliceSpans, } from './markdown.js';
|
|
11
|
+
import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, TASK_VIEWS, } from './pane-views.js';
|
|
12
|
+
import { isCompact, layoutBudget, searchPreviewCapacity, windowRange, } from './viewport.js';
|
|
13
|
+
import { cut, sanitize, termWidth } from './width.js';
|
|
24
14
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
25
|
-
import { initialDetail,
|
|
15
|
+
import { initialDetail, writeTaskEdit, PRI_GLYPH, PRI_LABEL } from './task-edit.js';
|
|
16
|
+
import { ALL, EDIT_PRI, EDIT_PROG, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, ROW_ALL, ROW_SPOT, SORT_TASTI, SPOT, } from './model.js';
|
|
17
|
+
import { conversationLabel, cpLen, editField, insertAt, isDone, isTextRow, removeAt, EDIT_ROWS, } from './layout.js';
|
|
18
|
+
import { TASK_EMPTY, relTime, sanitizeTyped } from './glyphs.js';
|
|
19
|
+
import { commitTaskEdit, runLaunch, spawnClaudeEmpty, spawnCreateTask, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, CLAUDE_CMD, DECK_RUN, } from './spawn.js';
|
|
20
|
+
import { EditModal, FilterModal, SortModal } from './ui/modals.js';
|
|
21
|
+
import { ReaderScreen, SearchScreen } from './ui/search-screen.js';
|
|
22
|
+
import { DetailScreen } from './ui/detail-screen.js';
|
|
23
|
+
import { AssignScreen } from './ui/assign-screen.js';
|
|
24
|
+
import { SessionsPane, TasksPane } from './ui/panes.js';
|
|
25
|
+
import { PreviewPane, detailMetaOf } from './ui/preview.js';
|
|
26
26
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
// ma fuori vista. Non-silenzioso → l'header mostra quante sono nascoste.
|
|
33
|
-
const MAX_SESSIONS = 30;
|
|
34
|
-
// T59 D3 — cap dedicato alla vista "tutte": a 30 su un progetto con ~170
|
|
35
|
-
// conversazioni la lista sarebbe esaustiva solo sull'ultimo 17%, cioè
|
|
36
|
-
// contraddirebbe lo scopo della riga. 100 copre la finestra temporale utile e
|
|
37
|
-
// tiene comunque un tetto alle righe da attraversare con ↑↓.
|
|
38
|
-
const MAX_SESSIONS_ALL = 100;
|
|
39
|
-
// Modello task-centrico: il Tasks pane ha, oltre alle task reali, DUE righe
|
|
40
|
-
// meta in testa — `≡ tutte` (ogni conversazione del progetto) e `○ spot` (le
|
|
41
|
-
// sole NON legate ad alcuna task). La selezione nel Tasks pane è il "padre"; il
|
|
42
|
-
// Sessions pane mostra i suoi figli.
|
|
43
|
-
//
|
|
44
|
-
// T59 D1 — le sentinelle sono Symbol, non `null` né stringhe riservate: la
|
|
45
|
-
// selezione è un ENUM a tre casi (task / spot / tutte), non un flag. Un Symbol
|
|
46
|
-
// non può collidere con un task id e si confronta secco (`sel === ALL`); una
|
|
47
|
-
// stringa sentinella farebbe invece circolare un id-fantasma dentro un tipo che
|
|
48
|
-
// altrove significa "task id".
|
|
49
|
-
const SPOT = Symbol('spot');
|
|
50
|
-
const ALL = Symbol('all');
|
|
51
|
-
// Le righe meta occupano le prime posizioni della lista: l'indice di una task
|
|
52
|
-
// nella VISTA è quindi il suo indice in `viewTasks` + META_ROWS.
|
|
53
|
-
const ROW_ALL = 0;
|
|
54
|
-
const ROW_SPOT = 1;
|
|
55
|
-
const META_ROWS = 2;
|
|
56
|
-
const EDIT_ROWS = 4;
|
|
57
|
-
/** Le righe del modale edit che sono campi di TESTO (il resto è scelta ←→). */
|
|
58
|
-
function isTextRow(r) {
|
|
59
|
-
return r === 2 || r === 3;
|
|
60
|
-
}
|
|
61
|
-
/** Chiave della bozza scritta dalla riga di testo `r`. */
|
|
62
|
-
function editField(r) {
|
|
63
|
-
return r === 2 ? 'detail' : 'title';
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Lunghezza in CODE POINT. Il caret ci indicizza sopra: `.length` conterebbe le
|
|
67
|
-
* code unit UTF-16 e un'emoji nel titolo varrebbe 2 posizioni, cioè un cursore
|
|
68
|
-
* che si ferma a metà glifo e un `slice` che lo spezza in due surrogati.
|
|
69
|
-
*/
|
|
70
|
-
function cpLen(s) {
|
|
71
|
-
return [...s].length;
|
|
72
|
-
}
|
|
73
|
-
/** Inserisce `ins` alla posizione `at` (code point). */
|
|
74
|
-
function insertAt(s, at, ins) {
|
|
75
|
-
const cp = [...s];
|
|
76
|
-
return cp.slice(0, at).join('') + ins + cp.slice(at).join('');
|
|
77
|
-
}
|
|
78
|
-
/** Toglie il code point in posizione `at`; fuori range = stringa invariata. */
|
|
79
|
-
function removeAt(s, at) {
|
|
80
|
-
const cp = [...s];
|
|
81
|
-
if (at < 0 || at >= cp.length)
|
|
82
|
-
return s;
|
|
83
|
-
cp.splice(at, 1);
|
|
84
|
-
return cp.join('');
|
|
85
|
-
}
|
|
86
|
-
// Modale sort a grammatica libera: un tasto per chiave, pressioni successive
|
|
87
|
-
// ciclano asc → desc → fuori dalla chain.
|
|
88
|
-
const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
|
|
89
|
-
// T52 — toggle del modale ricerca, tutti su CTRL (D2).
|
|
90
|
-
//
|
|
91
|
-
// I due campi di testo mangiano ogni lettera nuda, quindi un toggle non può
|
|
92
|
-
// essere una lettera semplice: resterebbero i caratteri della query. CTRL è
|
|
93
|
-
// l'unico livello che convive con la digitazione senza modi né navigazione.
|
|
94
|
-
//
|
|
95
|
-
// Le mnemoniche ovvie sono precluse dall'ASCII, non da una scelta di design:
|
|
96
|
-
// `^I` È il Tab (0x09) e `^H` È il Backspace (0x08) — stesso byte, nessuna
|
|
97
|
-
// distinzione possibile a valle. Quindi niente I=IA e niente H=human. Bruciati
|
|
98
|
-
// per lo stesso motivo `^M` (Enter), `^J` (LF), `^[` (Esc). Tutto il resto passa
|
|
99
|
-
// pulito, `^S`/`^Q` inclusi: il raw mode di Ink disattiva il flow-control XON/XOFF
|
|
100
|
-
// che altrimenti se li mangerebbe il terminale.
|
|
101
|
-
const SEARCH_TOGGLE_KEYS = {
|
|
102
|
-
r: 'regex',
|
|
103
|
-
a: 'caseSensitive',
|
|
104
|
-
w: 'wholeWord',
|
|
105
|
-
};
|
|
106
|
-
const SEARCH_KIND_KEYS = { b: 'ai', t: 'tool', u: 'human' };
|
|
107
|
-
const KIND_LABEL = { ai: 'IA', tool: 'tools', human: 'human' };
|
|
108
|
-
// T41 — ordine dei valori nel modale edit. Deliberatamente DIVERSO da
|
|
109
|
-
// PRI_ENTRIES/PROG_ENTRIES (che seguono il rango di sort): qui si sceglie un
|
|
110
|
-
// valore, non si ordina, quindi vince l'ordine del CICLO DI VITA — da fare →
|
|
111
|
-
// in corso → chiusa → bloccata. La priorità resta alta→bassa, che è già
|
|
112
|
-
// l'ordine naturale di lettura.
|
|
113
|
-
const EDIT_PRI = ['high', 'med', 'low'];
|
|
114
|
-
const EDIT_PROG = ['todo', 'wip', 'done', 'locked'];
|
|
115
|
-
function isDone(prog) {
|
|
116
|
-
return prog.includes('✔');
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* Freno agli effetti VERSO L'ESTERNO (tab Ptyxis, sessioni Claude, git commit).
|
|
120
|
-
*
|
|
121
|
-
* Il gate di larghezza avvia il deck vero in uno pseudo-terminale e gli manda
|
|
122
|
-
* tasti — e in questa TUI un tasto è un'azione: `⏎` su una riga sessione apre
|
|
123
|
-
* una tab Ptyxis, `t` un terminale, `⏎` nel modale edit committa. Ogni run dei
|
|
124
|
-
* test apriva quindi finestre reali sulla macchina di chi li lanciava, in
|
|
125
|
-
* qualunque progetto avesse in focus.
|
|
126
|
-
*
|
|
127
|
-
* Il gate va tenuto sul deck VERO (è tutto il suo valore: misura il frame che
|
|
128
|
-
* VTE disegna davvero), quindi il freno sta qui: `LOOM_DECK_NO_SPAWN=1` fa
|
|
129
|
-
* restituire un figlio finto e inerte invece di lanciare il processo. Non è un
|
|
130
|
-
* mock del comportamento — l'azione semplicemente non avviene, e il frame che il
|
|
131
|
-
* test misura resta identico.
|
|
132
|
-
*/
|
|
133
|
-
const NO_SPAWN = process.env.LOOM_DECK_NO_SPAWN === '1';
|
|
134
|
-
function spawnOut(cmd, args, opts) {
|
|
135
|
-
if (!NO_SPAWN)
|
|
136
|
-
return spawn(cmd, args, opts);
|
|
137
|
-
// Figlio inerte: emette nulla, quindi i `.on('error'|'close')` dei chiamanti
|
|
138
|
-
// restano appesi senza mai scattare — che è esattamente "non è successo niente".
|
|
139
|
-
const fake = new EventEmitter();
|
|
140
|
-
fake.unref = () => fake;
|
|
141
|
-
return fake;
|
|
142
|
-
}
|
|
143
|
-
// T66 — le azioni del detail. Non sono un catalogo nuovo: ognuna è un
|
|
144
|
-
// `--prompt-kind` già esistente più `checkpoint`, e tutte passano dallo stesso
|
|
145
|
-
// `spawnForTask` dei CTRL della lista — una superficie in più, zero percorsi di
|
|
146
|
-
// spawn in più.
|
|
147
|
-
//
|
|
148
|
-
// L'etichetta è distinta dal kind dove il kind è il nome del MECCANISMO e
|
|
149
|
-
// l'etichetta quello dell'INTENZIONE: `none` è "aprire la task a mani nude",
|
|
150
|
-
// `recap` è "vedere a che punto sta".
|
|
151
|
-
const DETAIL_ACTIONS = [
|
|
152
|
-
{ kind: 'none', label: 'open' },
|
|
153
|
-
{ kind: 'preflight', label: 'preflight' },
|
|
154
|
-
{ kind: 'run', label: 'run' },
|
|
155
|
-
{ kind: 'recap', label: 'status' },
|
|
156
|
-
{ kind: 'checkpoint', label: 'checkpoint' },
|
|
157
|
-
];
|
|
158
|
-
// Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
|
|
159
|
-
// ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
|
|
160
|
-
// sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
|
|
161
|
-
// Il kind è OBBLIGATORIO e non ha default qui: il default vive in deck-run (per
|
|
162
|
-
// le invocazioni a mano), mentre dal deck ogni tasto dichiara il proprio intento
|
|
163
|
-
// — un default silenzioso renderebbe indistinguibili `⏎` e `^K`.
|
|
164
|
-
function spawnDeck(id, cwd, sessionId, kind) {
|
|
165
|
-
const child = spawnOut(DECK_RUN, [id, '--session-id', sessionId, '--prompt-kind', kind], {
|
|
166
|
-
cwd,
|
|
167
|
-
detached: true,
|
|
168
|
-
stdio: 'ignore',
|
|
169
|
-
});
|
|
170
|
-
child.unref();
|
|
171
|
-
return child;
|
|
172
|
-
}
|
|
173
|
-
// T49 — resume di una sessione esistente come nuova tab Ptyxis. Scoped (taskId
|
|
174
|
-
// presente) → `deck-run <task> --resume <sid>`: la ripresa eredita LOOM_TASK +
|
|
175
|
-
// titolo `· <task>` (D2 preflight, l'hook SessionStart ricarica il contesto
|
|
176
|
-
// task). Spot → `--no-task --resume`: resume nudo, solo label progetto. Nessun
|
|
177
|
-
// prompt iniziale in entrambi i casi: riprendere una conversazione significa
|
|
178
|
-
// continuarla, non iniettarle un messaggio (lo salta deck-run).
|
|
179
|
-
//
|
|
180
|
-
// T64 — la NOTA della conversazione (se c'è) viaggia nel titolo della tab. Più
|
|
181
|
-
// sessioni sulla stessa task hanno oggi titoli identici (`label · T81`): la nota
|
|
182
|
-
// è già ciò con cui l'utente le distingue in lista, quindi è anche ciò che
|
|
183
|
-
// distingue le tab. La passa il DECK e non la legge deck-run perché la nota vive
|
|
184
|
-
// nel sidecar `session-tasks.jsonl`, che deck-run non tocca (legge solo
|
|
185
|
-
// `.claude/loom-works.json`): tenerlo così evita di dare al primitive un secondo
|
|
186
|
-
// file da conoscere. Il titolo si congela qui — `claude --name` lo setta una
|
|
187
|
-
// volta sola, quindi una nota cambiata DOPO non ri-titola la tab già aperta.
|
|
188
|
-
function spawnDeckResume(taskId, cwd, sessionId, note) {
|
|
189
|
-
const args = taskId ? [taskId, '--resume', sessionId] : ['--no-task', '--resume', sessionId];
|
|
190
|
-
if (note)
|
|
191
|
-
args.push('--title-note', note);
|
|
192
|
-
const child = spawnOut(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
|
|
193
|
-
child.unref();
|
|
194
|
-
return child;
|
|
195
|
-
}
|
|
196
|
-
// T28 — FORK: `deck-run <task|--no-task> --resume <origine> --fork --session-id
|
|
197
|
-
// <nuovo>`. Variante del resume, non una terza forma: cambia solo che CC apre un
|
|
198
|
-
// id nuovo (`--fork-session`) invece di riprendere a scrivere sull'origine —
|
|
199
|
-
// due writer sullo stesso JSONL non esistono mai, che è l'intero punto del fork.
|
|
200
|
-
// Il nuovo id lo genera il DECK e lo pinna, come in spawnDeck: è l'unico modo di
|
|
201
|
-
// conoscerlo prima che la sessione esista, e senza conoscerlo non si possono
|
|
202
|
-
// scrivere né il binding task né il record di lineage (il transcript del fork
|
|
203
|
-
// non nomina da nessuna parte la sessione d'origine).
|
|
204
|
-
// Nessun `--title-note` (T64): il ramo nasce con un sessionId proprio e SENZA
|
|
205
|
-
// nota nel sidecar — ereditare quella dell'origine metterebbe nel titolo una
|
|
206
|
-
// maniglia che nella lista non compare, cioè una promessa falsa. Il fork si
|
|
207
|
-
// distingue col suo marcatore, `· fork`.
|
|
208
|
-
function spawnDeckFork(taskId, cwd, originId, newId) {
|
|
209
|
-
const args = [
|
|
210
|
-
...(taskId ? [taskId] : ['--no-task']),
|
|
211
|
-
'--resume',
|
|
212
|
-
originId,
|
|
213
|
-
'--fork',
|
|
214
|
-
'--session-id',
|
|
215
|
-
newId,
|
|
216
|
-
];
|
|
217
|
-
const child = spawnOut(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
|
|
218
|
-
child.unref();
|
|
219
|
-
return child;
|
|
220
|
-
}
|
|
221
|
-
// T42 — sessione Claude NUDA: nessuna task, nessun prompt iniziale, nessun
|
|
222
|
-
// sessionId pinnato (quindi nessuna entry nel sidecar session-tasks.jsonl: senza
|
|
223
|
-
// task non c'è nulla da legare). Funzione separata e non un parametro opzionale
|
|
224
|
-
// di spawnDeck: i tre argomenti mancano tutti insieme, un `if` per ciascuno
|
|
225
|
-
// sporcherebbe il percorso bound. Il titolo tab resta la label loom — lo mette
|
|
226
|
-
// deck-run, perché il match compass è window-level e non sa nulla di task.
|
|
227
|
-
function spawnClaudeEmpty(cwd) {
|
|
228
|
-
const child = spawnOut(DECK_RUN, ['--no-task'], {
|
|
229
|
-
cwd,
|
|
230
|
-
detached: true,
|
|
231
|
-
stdio: 'ignore',
|
|
232
|
-
});
|
|
233
|
-
child.unref();
|
|
234
|
-
return child;
|
|
235
|
-
}
|
|
236
|
-
// T39/T32: voce `launch` custom del file config, eseguita con cwd = project root.
|
|
237
|
-
// Spawn detached come spawnDeck: il deck lancia ma non possiede il processo.
|
|
238
|
-
// Shell login+interattiva (bash -lic) perché i comandi tipici sono alias o
|
|
239
|
-
// funzioni di ~/.bashrc (`codium`=alias flatpak, `idea`=funzione) — con `bash -c`
|
|
240
|
-
// non risolverebbero. Il comando NON è input utente: viene dal file committato
|
|
241
|
-
// `.claude/loom-works.json`, fidato quanto un custom-command Ptyxis (contratto
|
|
242
|
-
// esplicito in project-config-architecture.md). La project root arriva via cwd,
|
|
243
|
-
// non interpolata nella stringa.
|
|
244
|
-
function runLaunch(entry, cwd) {
|
|
245
|
-
const child = spawnOut('bash', ['-lic', entry.command], {
|
|
246
|
-
cwd,
|
|
247
|
-
detached: true,
|
|
248
|
-
stdio: 'ignore',
|
|
249
|
-
});
|
|
250
|
-
child.unref();
|
|
251
|
-
return child;
|
|
252
|
-
}
|
|
253
|
-
// T37 — surface STANDARD LAUNCH `terminal`: built-in e universale (nessuna
|
|
254
|
-
// dichiarazione in `launch[]`), ma di natura launch — fire-once, nessuno stato.
|
|
255
|
-
// Il deck gira già DENTRO una tab Ptyxis → `--tab` mette il terminale accanto a
|
|
256
|
-
// sé nella stessa finestra, invece di sparpagliare finestre.
|
|
257
|
-
// Nessun `-- CMD`: l'azione È aprire la shell (differenza dalle launch custom,
|
|
258
|
-
// che eseguono un comando dentro `bash -lic`).
|
|
259
|
-
// `-T <title>` con la chiave `🖥️ <name>` tiene la finestra matchabile da compass
|
|
260
|
-
// anche mentre la tab attiva è il terminale; senza identità nel file config si
|
|
261
|
-
// spawna senza titolo (la surface resta funzionante, il progetto risulta assente
|
|
262
|
-
// dal radar finché quella tab è in primo piano).
|
|
263
|
-
function spawnTerminal(cwd, title) {
|
|
264
|
-
const args = title ? ['--tab', '-T', title, '-d', cwd] : ['--tab', '-d', cwd];
|
|
265
|
-
const child = spawnOut('ptyxis', args, { cwd, detached: true, stdio: 'ignore' });
|
|
266
|
-
child.unref();
|
|
267
|
-
return child;
|
|
268
|
-
}
|
|
269
|
-
// Comando claude (override per ambienti dove non è su PATH; loom-deck → NPM).
|
|
270
|
-
const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
|
|
271
|
-
// T30: create-task inline. Spawna CC HEADLESS (`-p`) con `--session-id` pinnato
|
|
272
|
-
// che invoca la skill create-task. Differenze da spawnDeck:
|
|
273
|
-
// - headless (`-p`), non una tab Ptyxis interattiva → il deck osserva l'esito;
|
|
274
|
-
// - `yolo` FORZATO: create-task è interattiva di default (AskUserQuestion) e in
|
|
275
|
-
// `-p` non può ricevere risposte → si impianterebbe. yolo = zero domande.
|
|
276
|
-
// - `--output-format stream-json` (richiede `--verbose`): l'ultima riga è
|
|
277
|
-
// `{type:"result", is_error}`, segnale di completamento robusto (> exit code).
|
|
278
|
-
// - detached (own process-group) → il create sopravvive alla chiusura del deck e
|
|
279
|
-
// completa commit+push da sé; stdout in pipe SOLO per leggere il result event.
|
|
280
|
-
// Il prompt viaggia come singolo argv (no shell) → nessuna injection dal testo utente.
|
|
281
|
-
function spawnCreateTask(text, cwd, sessionId, onResult) {
|
|
282
|
-
const child = spawnOut(CLAUDE_CMD, [
|
|
283
|
-
'-p',
|
|
284
|
-
'--output-format',
|
|
285
|
-
'stream-json',
|
|
286
|
-
'--verbose',
|
|
287
|
-
'--session-id',
|
|
288
|
-
sessionId,
|
|
289
|
-
`/loom-works:create-task yolo ${text}`,
|
|
290
|
-
], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
291
|
-
let buf = '';
|
|
292
|
-
let isError = null;
|
|
293
|
-
child.stdout?.on('data', (chunk) => {
|
|
294
|
-
buf += chunk.toString();
|
|
295
|
-
let nl;
|
|
296
|
-
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
297
|
-
const line = buf.slice(0, nl).trim();
|
|
298
|
-
buf = buf.slice(nl + 1);
|
|
299
|
-
if (!line)
|
|
300
|
-
continue;
|
|
301
|
-
try {
|
|
302
|
-
const obj = JSON.parse(line);
|
|
303
|
-
if (obj.type === 'result')
|
|
304
|
-
isError = obj.is_error ?? false;
|
|
305
|
-
}
|
|
306
|
-
catch {
|
|
307
|
-
// riga parziale / non-json → skip
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
});
|
|
311
|
-
// Drena stderr per non riempire il buffer pipe (deadlock del figlio).
|
|
312
|
-
child.stderr?.on('data', () => { });
|
|
313
|
-
child.on('error', () => onResult(false));
|
|
314
|
-
child.on('close', (code) => {
|
|
315
|
-
onResult(isError === null ? code === 0 : !isError);
|
|
316
|
-
});
|
|
317
|
-
return child;
|
|
318
|
-
}
|
|
319
|
-
// T41 — Commit dell'edit. `git commit -- <paths>` committa lo stato working-tree
|
|
320
|
-
// SOLO di quei path, ignorando l'index: se l'utente ha altro in stage (o altri
|
|
321
|
-
// file sporchi) non finisce dentro per errore. NON detached: è un'operazione
|
|
322
|
-
// veloce e il suo esito va riportato nella nota. stderr raccolto per dire perché
|
|
323
|
-
// ha fallito (identità git assente, hook che rifiuta, …) invece di un generico ⚠.
|
|
324
|
-
function commitTaskEdit(cwd, paths, message, onResult) {
|
|
325
|
-
const child = spawnOut('git', ['commit', '-m', message, '--', ...paths], {
|
|
326
|
-
cwd,
|
|
327
|
-
stdio: ['ignore', 'ignore', 'pipe'],
|
|
328
|
-
});
|
|
329
|
-
let err = '';
|
|
330
|
-
child.stderr?.on('data', (c) => {
|
|
331
|
-
err += c.toString();
|
|
332
|
-
});
|
|
333
|
-
child.on('error', () => onResult(false, 'git non lanciabile'));
|
|
334
|
-
child.on('close', (code) => onResult(code === 0, err.trim().split('\n')[0] ?? ''));
|
|
335
|
-
return child;
|
|
336
|
-
}
|
|
337
|
-
// Dimensioni del terminale, live sul resize.
|
|
338
|
-
//
|
|
339
|
-
// Non è una comodità di layout: senza `rows` il frame non ha tetto, e un frame
|
|
340
|
-
// più alto del terminale fa cadere Ink nel ramo `clearTerminal` (ink.js:121)
|
|
341
|
-
// che su VTE/Ptyxis riversa ogni redraw nello scrollback.
|
|
342
|
-
//
|
|
343
|
-
// Il valore iniziale conta quanto il resize: una tab Ptyxis appena aperta parte
|
|
344
|
-
// spesso a 24 righe e riceve il SIGWINCH subito dopo. Nella finestra fra i due
|
|
345
|
-
// il deck disegnava già a piena altezza — motivo per cui lo scrollback risultava
|
|
346
|
-
// sporco fin dall'avvio, prima ancora di toccare un tasto.
|
|
347
|
-
function useTerminalSize() {
|
|
348
|
-
const { stdout } = useStdout();
|
|
349
|
-
const [size, setSize] = useState({ rows: stdout.rows || 24, columns: stdout.columns || 80 });
|
|
350
|
-
useEffect(() => {
|
|
351
|
-
const onResize = () => setSize({ rows: stdout.rows || 24, columns: stdout.columns || 80 });
|
|
352
|
-
stdout.on('resize', onResize);
|
|
353
|
-
onResize(); // allinea se il resize è arrivato prima del mount
|
|
354
|
-
return () => {
|
|
355
|
-
stdout.off('resize', onResize);
|
|
356
|
-
};
|
|
357
|
-
}, [stdout]);
|
|
358
|
-
return size;
|
|
359
|
-
}
|
|
360
|
-
// Carica tasks.md e lo ri-legge quando cambia sotto (poll su mtime). Poll
|
|
361
|
-
// (non fs.watch) perché i writer di tasks.md — checkpoint-task/create-task —
|
|
362
|
-
// riscrivono il file (probabile replace atomico), che rompe il watch sull'inode
|
|
363
|
-
// originale; statSync(path) segue sempre il file corrente al path.
|
|
364
|
-
function useTasks(tasksPath) {
|
|
365
|
-
const [tasks, setTasks] = useState([]);
|
|
366
|
-
const [loadError, setLoadError] = useState('');
|
|
367
|
-
useEffect(() => {
|
|
368
|
-
let lastMtime = -1;
|
|
369
|
-
const reload = () => {
|
|
370
|
-
try {
|
|
371
|
-
const mtime = statSync(tasksPath).mtimeMs;
|
|
372
|
-
if (mtime === lastMtime)
|
|
373
|
-
return; // invariato → niente re-read
|
|
374
|
-
lastMtime = mtime;
|
|
375
|
-
setTasks(loadTasks(tasksPath));
|
|
376
|
-
setLoadError('');
|
|
377
|
-
}
|
|
378
|
-
catch {
|
|
379
|
-
lastMtime = -1; // così quando il file riappare viene ri-letto
|
|
380
|
-
setTasks([]);
|
|
381
|
-
setLoadError(`tasks.md non leggibile: ${tasksPath}`);
|
|
382
|
-
}
|
|
383
|
-
};
|
|
384
|
-
reload();
|
|
385
|
-
const id = setInterval(reload, POLL_MS);
|
|
386
|
-
return () => clearInterval(id);
|
|
387
|
-
}, [tasksPath]);
|
|
388
|
-
return { tasks, loadError };
|
|
389
|
-
}
|
|
390
|
-
// Poll delle sessioni del progetto + binding sidecar. discoverProjectSessions
|
|
391
|
-
// ha cache mtime-keyed interna → il poll è economico; qui si evita comunque il
|
|
392
|
-
// re-render inutile con una signature (sessionId:ts + binding entries): setState
|
|
393
|
-
// solo quando cambia davvero qualcosa.
|
|
394
|
-
function useSessions(projectRoot) {
|
|
395
|
-
const [state, setState] = useState({
|
|
396
|
-
sessions: [],
|
|
397
|
-
bindings: new Map(),
|
|
398
|
-
forkOf: new Map(),
|
|
399
|
-
pinned: new Map(),
|
|
400
|
-
notes: new Map(),
|
|
401
|
-
live: new Map(),
|
|
402
|
-
});
|
|
403
|
-
// T50 — pin/unpin scrive il sidecar e vuole feedback IMMEDIATO, non al
|
|
404
|
-
// prossimo tick del poll (1.5s): la reload è esposta via ref così il toggle la
|
|
405
|
-
// richiama senza risottoscrivere l'intervallo.
|
|
406
|
-
const reloadRef = useRef(() => { });
|
|
407
|
-
useEffect(() => {
|
|
408
|
-
let lastSig = '';
|
|
409
|
-
const reload = () => {
|
|
410
|
-
let sessions;
|
|
411
|
-
let index;
|
|
412
|
-
try {
|
|
413
|
-
sessions = discoverProjectSessions(projectRoot);
|
|
414
|
-
index = loadSessionIndex(projectRoot);
|
|
415
|
-
}
|
|
416
|
-
catch {
|
|
417
|
-
sessions = [];
|
|
418
|
-
index = { bindings: new Map(), forkOf: new Map(), pinned: new Map(), notes: new Map() };
|
|
419
|
-
}
|
|
420
|
-
// T62 — le vive stanno sullo STESSO tick delle altre fonti, non su una
|
|
421
|
-
// scala propria come `useArchivable`: `status` cambia a ogni turno, quindi
|
|
422
|
-
// un refresh più lento mostrerebbe `idle` su una sessione che lavora.
|
|
423
|
-
// Il try è separato perché il registry è una fonte indipendente: se manca
|
|
424
|
-
// (versione del CLI che non lo scrive) la lista deve restare, senza vive.
|
|
425
|
-
let live;
|
|
426
|
-
try {
|
|
427
|
-
live = discoverLiveSessions(projectRoot);
|
|
428
|
-
}
|
|
429
|
-
catch {
|
|
430
|
-
live = new Map();
|
|
431
|
-
}
|
|
432
|
-
const { bindings, forkOf, pinned, notes } = index;
|
|
433
|
-
// La signature copre anche fork, pin e note: un record di lineage, un
|
|
434
|
-
// toggle di pin o una nota appena scritta cambiano la lista renderizzata,
|
|
435
|
-
// quindi devono forzare il re-render come farebbe un binding nuovo.
|
|
436
|
-
const sig = sessions.map((s) => `${s.sessionId}:${s.ts}`).join('|') +
|
|
437
|
-
'#' +
|
|
438
|
-
[...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',') +
|
|
439
|
-
'#' +
|
|
440
|
-
[...forkOf.entries()].map(([k, v]) => `${k}<${v}`).sort().join(',') +
|
|
441
|
-
'#' +
|
|
442
|
-
[...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',') +
|
|
443
|
-
'#' +
|
|
444
|
-
[...notes.entries()].map(([k, v]) => `${k}"${v}`).sort().join(',') +
|
|
445
|
-
'#' +
|
|
446
|
-
liveSig(live);
|
|
447
|
-
if (sig === lastSig)
|
|
448
|
-
return;
|
|
449
|
-
lastSig = sig;
|
|
450
|
-
setState({ sessions, bindings, forkOf, pinned, notes, live });
|
|
451
|
-
};
|
|
452
|
-
reloadRef.current = reload;
|
|
453
|
-
reload();
|
|
454
|
-
const id = setInterval(reload, POLL_MS);
|
|
455
|
-
return () => clearInterval(id);
|
|
456
|
-
}, [projectRoot]);
|
|
457
|
-
return { ...state, reload: () => reloadRef.current() };
|
|
458
|
-
}
|
|
459
|
-
// T61 — conteggio delle Done oltre soglia, su una scala di refresh TUTTA SUA.
|
|
460
|
-
//
|
|
461
|
-
// Non è appeso a POLL_MS (1,5s) come tasks.md e le sessioni: l'età di una task
|
|
462
|
-
// cambia una volta al giorno, e ogni giro costa la lettura di N task file più
|
|
463
|
-
// qualche spawn di git. Due trigger:
|
|
464
|
-
//
|
|
465
|
-
// · quando cambia l'INSIEME delle task Done (`doneSig`) — copre l'avvio, dove
|
|
466
|
-
// il primo render ha `tasks` ancora vuoto, e la chiusura di una task, dove
|
|
467
|
-
// il numero deve muoversi senza aspettare ore;
|
|
468
|
-
// · ogni SCAN_INTERVAL_MS — copre il caso opposto, in cui non cambia nulla
|
|
469
|
-
// sul disco ed è il calendario a far scattare una task oltre soglia.
|
|
470
|
-
//
|
|
471
|
-
// `doneSig` è una stringa, non l'array: `tasks` cambia identità a ogni re-read
|
|
472
|
-
// di tasks.md, e usarlo come dipendenza rimetterebbe lo scan sul tick da 1,5s
|
|
473
|
-
// per la via di dietro.
|
|
474
|
-
//
|
|
475
|
-
// T100 — tiene gli ID e non più il conteggio: `archiviabili` è una vista del
|
|
476
|
-
// pane, quindi servono le righe. Il contatore dell'header è `.size` dello stesso
|
|
477
|
-
// insieme che disegna la lista — un numero e una lista che non possono divergere.
|
|
478
|
-
function useArchivable(doneSig, tasksDir, projectRoot, days) {
|
|
479
|
-
const [ids, setIds] = useState(() => new Set());
|
|
480
|
-
useEffect(() => {
|
|
481
|
-
let alive = true;
|
|
482
|
-
const done = doneSig ? doneSig.split(',') : [];
|
|
483
|
-
const scan = () => {
|
|
484
|
-
archivableIds(done, { tasksDir, projectRoot, days })
|
|
485
|
-
.then((found) => {
|
|
486
|
-
if (alive)
|
|
487
|
-
setIds(new Set(found));
|
|
488
|
-
})
|
|
489
|
-
// Scan fallito (task file illeggibili, git muto) → insieme vuoto, cioè
|
|
490
|
-
// voce a 0. Un contatore informativo non merita un errore a schermo.
|
|
491
|
-
.catch(() => {
|
|
492
|
-
if (alive)
|
|
493
|
-
setIds(new Set());
|
|
494
|
-
});
|
|
495
|
-
};
|
|
496
|
-
scan();
|
|
497
|
-
const id = setInterval(scan, SCAN_INTERVAL_MS);
|
|
498
|
-
return () => {
|
|
499
|
-
alive = false;
|
|
500
|
-
clearInterval(id);
|
|
501
|
-
};
|
|
502
|
-
}, [doneSig, tasksDir, projectRoot, days]);
|
|
503
|
-
return ids;
|
|
504
|
-
}
|
|
505
|
-
// Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
|
|
506
|
-
// con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
|
|
507
|
-
// niente debounce serve per la tastiera. Il refresh del contenuto a file fermo
|
|
508
|
-
// (es. checkpoint aggiorna Progress) è demandato al prossimo cambio selezione.
|
|
509
|
-
function useTaskDetail(tasksDir, id) {
|
|
510
|
-
const [detail, setDetail] = useState(null);
|
|
511
|
-
useEffect(() => {
|
|
512
|
-
setDetail(id ? loadTaskDetail(tasksDir, id) : null);
|
|
513
|
-
}, [tasksDir, id]);
|
|
514
|
-
return detail;
|
|
515
|
-
}
|
|
516
|
-
// Glifi LETTERALI del JSX. I dati passano dai loader, che sanificano al
|
|
517
|
-
// confine; questi no — quindi passano da `sanitize` una volta qui, così nessun
|
|
518
|
-
// sito di render scrive un glifo nudo. `↳ ○ ▸ ⏎ · − ↑ ↓` sono già concordi e
|
|
519
|
-
// restano intatti; `▶` e `⚠` sono discordi e vengono sostituiti (`width.ts`).
|
|
520
|
-
const CARET = sanitize('▶ ');
|
|
521
|
-
const CARET_OFF = ' ';
|
|
522
|
-
const WARN = sanitize('⚠');
|
|
523
|
-
// T50 — separatore leggero fra blocco pinnate e contestuali. `─` (box-drawing) è
|
|
524
|
-
// largo 1 sia per string-width sia per il terminale. Corto +
|
|
525
|
-
// wrap="truncate-end" così non va mai a capo nel pane al 50%.
|
|
526
|
-
const SESSION_SEP = '─'.repeat(16);
|
|
527
|
-
// Prefisso del sessionId mostrato in lista: stesso dato e stessa lunghezza del
|
|
528
|
-
// widget `⛓ <8 char>` della statusline, così le due superfici si confrontano a
|
|
529
|
-
// occhio.
|
|
530
|
-
const SID_CHARS = 8;
|
|
531
|
-
/** T60 — segnaposto della colonna task su una riga senza binding. Una cella
|
|
532
|
-
* vuota di soli spazi lascerebbe un buco che si legge come "colonna finita",
|
|
533
|
-
* e la riga tornerebbe a sembrare disallineata pur non essendolo. */
|
|
534
|
-
const TASK_EMPTY = '·';
|
|
535
|
-
// T62 — colonna liveness, larga 1, incollata al sessionId senza gutter proprio:
|
|
536
|
-
// il glifo qualifica QUELL'id, e uno spazio in mezzo lo farebbe leggere come una
|
|
537
|
-
// colonna a sé. Entrambi Ambiguous (EAW) → 1 colonna per il terminale e 1 cella
|
|
538
|
-
// per Ink, quindi concordi (invariante ① di width.ts): il pieno/vuoto del
|
|
539
|
-
// cerchio è l'unico asse che varia, e resta scandibile in verticale.
|
|
540
|
-
//
|
|
541
|
-
// Sulla riga CHIUSA c'è uno spazio e non un terzo glifo: le chiuse sono la
|
|
542
|
-
// maggioranza di ogni lista, e marcarle vorrebbe dire disegnare N volte «niente
|
|
543
|
-
// da dire» — la colonna diventerebbe rumore invece di un segnale.
|
|
544
|
-
const LIVE_IDLE = '●';
|
|
545
|
-
const LIVE_BUSY = '◍';
|
|
546
|
-
const LIVE_NONE = ' ';
|
|
547
|
-
// Marker Done per il DISPLAY. `task.prog` resta il `✔️` letto da tasks.md —
|
|
548
|
-
// `isDone()` e le lookup di `view.ts` ci confrontano sopra, e `task-edit` lo
|
|
549
|
-
// riscrive sul file: è una chiave semantica, non testo. Qui `sanitize` lo
|
|
550
|
-
// traduce nel suo gemello concorde (`✅`) solo per finire nel frame.
|
|
551
|
-
function displayProg(prog) {
|
|
552
|
-
return sanitize(prog);
|
|
553
|
-
}
|
|
554
|
-
// T49 — size umana compatta per il detail pane sessione.
|
|
555
|
-
function fmtSize(bytes) {
|
|
556
|
-
if (bytes < 1024)
|
|
557
|
-
return `${bytes} B`;
|
|
558
|
-
if (bytes < 1024 * 1024)
|
|
559
|
-
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
560
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
561
|
-
}
|
|
562
|
-
// T49 — ultima attività ESTESA (giorno/mese ora:minuti) per il detail pane;
|
|
563
|
-
// nella riga di lista resta il relTime compatto.
|
|
564
|
-
function fmtDateTime(ts) {
|
|
565
|
-
const d = new Date(ts);
|
|
566
|
-
const p = (n) => String(n).padStart(2, '0');
|
|
567
|
-
return `${p(d.getDate())}/${p(d.getMonth() + 1)} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
568
|
-
}
|
|
569
|
-
// Età relativa compatta (ms epoch → "2m"/"3h"/"5d") per il preview sessioni.
|
|
570
|
-
function relTime(ts) {
|
|
571
|
-
const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
572
|
-
if (sec < 60)
|
|
573
|
-
return `${sec}s`;
|
|
574
|
-
const min = Math.floor(sec / 60);
|
|
575
|
-
if (min < 60)
|
|
576
|
-
return `${min}m`;
|
|
577
|
-
const hr = Math.floor(min / 60);
|
|
578
|
-
if (hr < 24)
|
|
579
|
-
return `${hr}h`;
|
|
580
|
-
return `${Math.floor(hr / 24)}d`;
|
|
581
|
-
}
|
|
582
|
-
/**
|
|
583
|
-
* Ripulisce un chunk di stdin prima di scriverlo in un campo di testo.
|
|
584
|
-
*
|
|
585
|
-
* `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio — o
|
|
586
|
-
* una raffica piu' veloce di una read — arriva come stringa unica, byte di
|
|
587
|
-
* controllo compresi. Non si vedono a schermo, ma Ink li conta nella larghezza
|
|
588
|
-
* della riga, e in un campo che finisce su disco (la nota, T53) resterebbero
|
|
589
|
-
* li' per sempre. Le newline diventano SPAZIO invece di sparire: incollare due
|
|
590
|
-
* righe deve separare le parole, non fonderle.
|
|
591
|
-
*/
|
|
592
|
-
function sanitizeTyped(s) {
|
|
593
|
-
return s.replace(/[\r\n]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
|
|
594
|
-
}
|
|
595
|
-
const META_KEYS = ['Priority', 'Size', 'Estimated Time', 'Progress'];
|
|
27
|
+
import { useArchivable, useSessions, useTaskDetail, useTasks, useTerminalSize } from './hooks.js';
|
|
28
|
+
import { useSearchOverlay } from './overlays/search.js';
|
|
29
|
+
import { useSheetOverlay } from './overlays/sheet.js';
|
|
30
|
+
import { useAssignOverlay } from './overlays/assign.js';
|
|
31
|
+
import { captures } from './input-modes.js';
|
|
596
32
|
function Deck({ cwd, tasksPath, tasksDir }) {
|
|
597
33
|
const { exit } = useApp();
|
|
598
34
|
const { tasks, loadError } = useTasks(tasksPath);
|
|
@@ -628,7 +64,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
628
64
|
// modale: la lista si aggiorna dal vivo, quindi `esc` deve poter ripristinare.
|
|
629
65
|
const [view, setView] = useState(() => loadView(cwd));
|
|
630
66
|
const [viewBackup, setViewBackup] = useState(null);
|
|
631
|
-
// T100 — vista attiva di ciascun pane, navigata con
|
|
67
|
+
// T100 — vista attiva di ciascun pane, navigata con `tab`. VOLATILE per
|
|
632
68
|
// decisione (D3 create): non entra in `deck-view.json`, il deck riapre sempre
|
|
633
69
|
// su `Tasks` e su `{parent}`. Il criterio è il rischio di leggere una lista
|
|
634
70
|
// parziale credendola completa — un filtro salvato lo si è scelto, una vista
|
|
@@ -639,53 +75,6 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
639
75
|
// T41 — bozza dell'edit (null fuori dal modale) e riga attiva della griglia.
|
|
640
76
|
const [edit, setEdit] = useState(null);
|
|
641
77
|
const [editRow, setEditRow] = useState(0);
|
|
642
|
-
// T52 — stato del modale ricerca. VOLATILE per decisione (D4): vive in
|
|
643
|
-
// memoria, quindi riaprendo il modale ritrovi query e toggle come li avevi
|
|
644
|
-
// lasciati, ma un riavvio del deck riporta ai default. Niente schema nuovo su
|
|
645
|
-
// `deck-view.json` e nessuna scrittura implicita su disco — che
|
|
646
|
-
// contraddirebbe la regola T39 «il disco si tocca solo su `w`», qui non
|
|
647
|
-
// trasportabile perché dentro il modale `w` è un carattere digitabile.
|
|
648
|
-
const [searchHash, setSearchHash] = useState('');
|
|
649
|
-
const [searchQuery, setSearchQuery] = useState('');
|
|
650
|
-
const [searchField, setSearchField] = useState('query');
|
|
651
|
-
const [searchOpts, setSearchOpts] = useState(DEFAULT_OPTIONS);
|
|
652
|
-
const [searchSelKey, setSearchSelKey] = useState(null);
|
|
653
|
-
// Occorrenza aperta nel reader; null = reader chiuso. Tenerla separata dalla
|
|
654
|
-
// selezione della lista è ciò che permette a `esc` di tornare indietro
|
|
655
|
-
// trovando la lista esattamente com'era.
|
|
656
|
-
const [readerRow, setReaderRow] = useState(null);
|
|
657
|
-
const [readerTop, setReaderTop] = useState(0);
|
|
658
|
-
// T57 — stato del modale di assegnazione sessione → task.
|
|
659
|
-
//
|
|
660
|
-
// `assignSid` è la conversazione in assegnazione, FOTOGRAFATA all'apertura e
|
|
661
|
-
// non riletta da `selSessionId`: il modale è fullscreen (D3), quindi il pane
|
|
662
|
-
// sessioni non è più a schermo e l'oggetto dell'azione deve restare quello
|
|
663
|
-
// che si è scelto — anche se un tick del poll rimescolasse la lista sotto.
|
|
664
|
-
// `assignSel` è la task di destinazione: `null` è la riga `detach` (D2), che
|
|
665
|
-
// sta sempre in testa e scrive un binding vuoto.
|
|
666
|
-
const [assignSid, setAssignSid] = useState(null);
|
|
667
|
-
const [assignFilter, setAssignFilter] = useState('');
|
|
668
|
-
const [assignSel, setAssignSel] = useState(null);
|
|
669
|
-
// T66 — la task aperta nel detail, FOTOGRAFATA all'apertura: id, titolo e
|
|
670
|
-
// testo integrale del task file (`text` null = file assente). Non si rilegge
|
|
671
|
-
// da `selTask` per la stessa ragione di `assignSid`: l'overlay copre la lista,
|
|
672
|
-
// quindi l'oggetto dell'azione deve restare quello che si è scelto anche se un
|
|
673
|
-
// tick del poll spostasse la selezione sotto.
|
|
674
|
-
//
|
|
675
|
-
// Si chiama `sheet` e non `detail` perché `detail` in questo componente è già
|
|
676
|
-
// il `TaskDetail` del blocco preview sotto i pane: due cose vicine con lo
|
|
677
|
-
// stesso nome sono una trappola di lettura (stesso motivo di
|
|
678
|
-
// `note`/`sessionNotes`).
|
|
679
|
-
const [sheet, setSheet] = useState(null);
|
|
680
|
-
const [sheetTop, setSheetTop] = useState(0);
|
|
681
|
-
const [sheetAction, setSheetAction] = useState(0);
|
|
682
|
-
// T91 — ricerca dentro il detail. `open` distingue i due modi in cui la si
|
|
683
|
-
// lascia: `esc` butta via ciò che il modale ha prodotto (`find` a null,
|
|
684
|
-
// evidenziazione via), `⏎` lo congela e restituisce il controllo allo strato
|
|
685
|
-
// sotto — campo chiuso, occorrenze ancora colorate, scroll dov'era. Senza il
|
|
686
|
-
// flag i due gesti collasserebbero su uno solo.
|
|
687
|
-
const [find, setFind] = useState(null);
|
|
688
|
-
const [occIdx, setOccIdx] = useState(0);
|
|
689
78
|
// Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
|
|
690
79
|
const { rows, columns } = useTerminalSize();
|
|
691
80
|
// Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
|
|
@@ -805,101 +194,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
805
194
|
// perderebbero il segnaposto e con lui l'allineamento.
|
|
806
195
|
return { task: task > 0 ? Math.max(task, termWidth(TASK_EMPTY)) : 0, age };
|
|
807
196
|
}, [sessionRows, bindings, isAll]);
|
|
808
|
-
// T52 — ricerca EAGER: rigira a ogni carattere digitato, non su ⏎. È
|
|
809
|
-
// sostenibile perché i corpi sono già in RAM dentro la cache mtime-keyed
|
|
810
|
-
// dell'adapter (D5): misurato su questo progetto, 0,8 ms sui soli corpi IA e
|
|
811
|
-
// ≤9 ms su tutti i tipi — sotto il tempo fra due battute. Il memo evita di
|
|
812
|
-
// rifarla sui re-render che non toccano né query né opzioni (il poll delle
|
|
813
|
-
// sessioni ogni 1,5s è già filtrato dalla signature in `useSessions`).
|
|
814
|
-
const searchResult = useMemo(() => searchSessions(sessions, searchHash, searchQuery, searchOpts, searchExcerptWidth(columns)), [sessions, searchHash, searchQuery, searchOpts, columns]);
|
|
815
|
-
// Con l'hash valorizzato la conversazione è una sola e già nominata nel campo:
|
|
816
|
-
// la riga-sessione ripeterebbe un dato costante rubando una riga per gruppo.
|
|
817
|
-
const searchFlat = searchHash.trim().length > 0;
|
|
818
|
-
const searchRows = useMemo(() => buildRows(searchResult, searchFlat), [searchResult, searchFlat]);
|
|
819
|
-
// T52 — riga selezionata e, se è un'occorrenza, il suo corpo wrappato per
|
|
820
|
-
// l'anteprima sotto la lista. Memoizzato per (testo, larghezza): navigando
|
|
821
|
-
// con le frecce si ri-wrappa solo quando cambia davvero l'occorrenza.
|
|
822
|
-
const searchSelRow = useMemo(() => selectedRow(searchRows, searchSelKey), [searchRows, searchSelKey]);
|
|
823
|
-
const searchPreviewWidth = Math.max(20, (columns || 80) - 8);
|
|
824
|
-
const searchPreviewBody = useMemo(() => searchSelRow?.kind === 'hit'
|
|
825
|
-
? wrapWithOffsets(searchSelRow.hit.text, searchPreviewWidth)
|
|
826
|
-
: [], [searchSelRow, searchPreviewWidth]);
|
|
827
|
-
// T52 — corpo del messaggio aperto nel reader, wrappato UNA volta per (testo,
|
|
828
|
-
// larghezza). Senza memo ogni pressione di freccia rifarebbe l'a-capo di un
|
|
829
|
-
// messaggio che nella coda lunga arriva a 150k char.
|
|
830
|
-
const readerWidth = Math.max(20, (columns || 80) - 6);
|
|
831
|
-
const readerLines = useMemo(() => (readerRow?.kind === 'hit' ? wrapWithOffsets(readerRow.hit.text, readerWidth) : []), [readerRow, readerWidth]);
|
|
832
|
-
const readerCap = readerCapacity(rows);
|
|
833
|
-
const readerMaxTop = Math.max(0, readerLines.length - readerCap);
|
|
834
197
|
// T66 — testo del task file wrappato per il detail. `wrapWithOffsets` e non
|
|
835
198
|
// `wrapLines`: quest'ultimo appiattisce gli a-capo in un flusso unico, che per
|
|
836
199
|
// una preview di 4 righe va bene e per un task file — titoli, bullet, tabelle,
|
|
837
|
-
// blocchi di codice — significa renderlo illeggibile. Memoizzato per (testo,
|
|
838
|
-
// larghezza), o ogni pressione di freccia rifarebbe l'a-capo di 9KB.
|
|
839
|
-
//
|
|
840
|
-
// Cornici da scalare: box esterno (2 bordi + 2 padding) + box testo (2 bordi +
|
|
841
|
-
// 2 padding) = 8. Sottostimare tronca un carattere, sovrastimare manda a capo
|
|
842
|
-
// una riga che il budget d'altezza non ha contato.
|
|
843
|
-
const sheetWidth = Math.max(20, (columns || 80) - 8);
|
|
844
|
-
// T75 — il markdown si rende PRIMA del wrap, e il resto della catena lavora
|
|
845
|
-
// sul testo reso: `**foo**` occupa 3 colonne rese e 7 grezze, quindi
|
|
846
|
-
// wrappare sui marker manderebbe a capo su un conteggio che il terminale non
|
|
847
|
-
// disegna. Memo separato dal wrap perché il parse dipende solo dal testo: un
|
|
848
|
-
// resize ri-wrappa 9KB, non li ri-parsa.
|
|
849
|
-
const sheetDoc = useMemo(() => (sheet?.text ? parseMarkdown(sheet.text) : null), [sheet]);
|
|
850
|
-
// Le righe conservano i propri offset invece di essere appiattite a stringa
|
|
851
|
-
// (T66 le buttava con `.map((l) => l.text)`): è ciò che rende
|
|
852
|
-
// l'evidenziazione un'intersezione di intervalli invece di un caso speciale
|
|
853
|
-
// per il match spezzato dall'a-capo. Dopo T75 gli offset indicizzano il testo
|
|
854
|
-
// RESO, ed è l'unica coordinata coerente che resti — il sorgente non è più
|
|
855
|
-
// ciò che sta a schermo.
|
|
856
|
-
const sheetLines = useMemo(() => (sheetDoc ? wrapWithOffsets(sheetDoc.text, sheetWidth) : []), [sheetDoc, sheetWidth]);
|
|
857
|
-
const sheetCap = detailCapacity(rows, find?.open === true);
|
|
858
|
-
const sheetMaxTop = Math.max(0, sheetLines.length - sheetCap);
|
|
859
|
-
// Lo scan gira sulla STESSA stringa che si renderizza: cercare su un testo
|
|
860
|
-
// diverso da quello a schermo darebbe offset che indicizzano un altro
|
|
861
|
-
// documento, cioè un'evidenziazione spostata di N caratteri e nessun errore.
|
|
862
|
-
//
|
|
863
|
-
// Dopo T75 quella stringa è il testo RESO, non più il sorgente: si cerca ciò
|
|
864
|
-
// che si vede. Ne discende che `**` non è più cercabile — è la conseguenza
|
|
865
|
-
// voluta, perché a schermo non c'è; e `Priority`, che prima era `**Priority**`
|
|
866
|
-
// e si trovava lo stesso, continua a trovarsi.
|
|
867
|
-
const findRes = useMemo(() => find && sheetDoc
|
|
868
|
-
? scanText(sheetDoc.text, find.q)
|
|
869
|
-
: { occ: [], error: '' }, [find?.q, sheetDoc]);
|
|
870
|
-
// L'indice si clampa qui invece di essere corretto a ogni `setOccIdx`: la
|
|
871
|
-
// lista si accorcia da sola mentre si digita, e un indice fuori range vivrebbe
|
|
872
|
-
// per il tempo di un render.
|
|
873
|
-
const occCur = findRes.occ.length > 0 ? Math.min(occIdx, findRes.occ.length - 1) : -1;
|
|
874
|
-
// Salto all'occorrenza corrente, centrata. Non dipende da `sheetTop`, quindi
|
|
875
|
-
// non si auto-rilancia; dipende da `sheetLines` e `sheetCap`, quindi un resize
|
|
876
|
-
// ricalcola la posizione senza toccare le occorrenze — che sono offset del
|
|
877
|
-
// sorgente e il resize non le sposta.
|
|
878
|
-
useEffect(() => {
|
|
879
|
-
const o = occCur >= 0 ? findRes.occ[occCur] : undefined;
|
|
880
|
-
if (!o)
|
|
881
|
-
return;
|
|
882
|
-
setSheetTop(topForOffset(sheetLines, o.start, sheetCap));
|
|
883
|
-
}, [findRes, occCur, sheetLines, sheetCap]);
|
|
884
|
-
// T57 — righe del modale assegnazione: `null` (detach) in testa, poi le task
|
|
885
|
-
// della VISTA corrente (D4 — filtri e sort inclusi, coerenza con ciò che si
|
|
886
|
-
// stava leggendo a sinistra; le escluse restano contate nell'header).
|
|
887
|
-
//
|
|
888
|
-
// Il filtro è un substring case-insensitive su id + titolo grezzo (D5): un
|
|
889
|
-
// restringimento veloce, non una ricerca — quella è `^F` e ha il suo motore.
|
|
890
|
-
// Il solo id non basterebbe, perché il caso d'uso nasce proprio dal «ho
|
|
891
|
-
// capito ora che questa conversazione è la task del titolo X».
|
|
892
|
-
//
|
|
893
|
-
// `detach` NON si filtra: è l'azione di svuotamento, non una task, e sparire
|
|
894
|
-
// digitando la renderebbe raggiungibile solo a campo vuoto.
|
|
895
|
-
const assignRows = useMemo(() => {
|
|
896
|
-
const q = assignFilter.trim().toLowerCase();
|
|
897
|
-
const matched = q
|
|
898
|
-
? viewTasks.filter((t) => t.id.toLowerCase().includes(q) || t.rawDesc.toLowerCase().includes(q))
|
|
899
|
-
: viewTasks;
|
|
900
|
-
return [null, ...matched];
|
|
901
|
-
}, [viewTasks, assignFilter]);
|
|
902
|
-
const assignCap = assignListCapacity(rows, Boolean(note));
|
|
903
200
|
// T39 — selezione stabile sotto trasformazione. Se la task selezionata esce
|
|
904
201
|
// dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
|
|
905
202
|
// sulla prima visibile — fallback deterministico, mai una posizione a caso.
|
|
@@ -917,25 +214,6 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
917
214
|
setSelSessionId(firstSelectableId(sessionRows));
|
|
918
215
|
}
|
|
919
216
|
}, [sessionRows, selSessionId]);
|
|
920
|
-
// T52 — stessa invariante sulla lista occorrenze, dove è ancora più stretta:
|
|
921
|
-
// la lista si RICOSTRUISCE a ogni carattere digitato, quindi la chiave
|
|
922
|
-
// selezionata sparisce continuamente. Persa → prima riga, mai una posizione
|
|
923
|
-
// ereditata (che qui punterebbe a un'altra conversazione, non a un'altra riga).
|
|
924
|
-
useEffect(() => {
|
|
925
|
-
if (rowIndexOfKey(searchRows, searchSelKey) < 0) {
|
|
926
|
-
setSearchSelKey(firstRowKey(searchRows));
|
|
927
|
-
}
|
|
928
|
-
}, [searchRows, searchSelKey]);
|
|
929
|
-
// T57 — la lista del modale si restringe a ogni carattere digitato: quando la
|
|
930
|
-
// task selezionata esce dal filtro si cade sulla PRIMA TASK, non su `detach`.
|
|
931
|
-
// Cadere su detach significherebbe che un `⏎` battuto di slancio dopo aver
|
|
932
|
-
// digitato staccherebbe la sessione invece di assegnarla — l'esatto opposto
|
|
933
|
-
// dell'intenzione. Senza match la selezione resta detach: è l'unica riga viva.
|
|
934
|
-
useEffect(() => {
|
|
935
|
-
if (assignSel !== null && !assignRows.some((t) => t?.id === assignSel)) {
|
|
936
|
-
setAssignSel(assignRows[1]?.id ?? null);
|
|
937
|
-
}
|
|
938
|
-
}, [assignRows, assignSel]);
|
|
939
217
|
// T30: submit dell'input box. Il taskId nasce DOPO create-task (lo assegna la
|
|
940
218
|
// skill scrivendo tasks.md) → non è noto allo spawn. Il sessionId invece è
|
|
941
219
|
// pinnato qui: snapshot degli id PRIMA, poi al completamento re-leggo tasks.md
|
|
@@ -990,7 +268,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
990
268
|
// messaggi a mano. `verb` è l'unica cosa che cambia fra i due usi.
|
|
991
269
|
function selectedTaskOr(keyLabel, verb) {
|
|
992
270
|
if (focus !== 'tasks') {
|
|
993
|
-
setNote(`${keyLabel} → ${verb}: seleziona una task (
|
|
271
|
+
setNote(`${keyLabel} → ${verb}: seleziona una task (← per il pane)`);
|
|
994
272
|
return null;
|
|
995
273
|
}
|
|
996
274
|
// T59 — la guardia è "non è una task", non "è spot": le righe meta sono due
|
|
@@ -1020,52 +298,6 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1020
298
|
if (task)
|
|
1021
299
|
spawnForTask(task.id, kind, keyLabel);
|
|
1022
300
|
}
|
|
1023
|
-
// T66 — `⏎` sul pane task apre il detail invece di spawnare. Il testo del task
|
|
1024
|
-
// file si legge QUI e non a ogni cambio selezione: l'overlay è l'unico
|
|
1025
|
-
// consumer, e leggerlo in anticipo pagherebbe un file a ogni pressione di
|
|
1026
|
-
// freccia per una schermata che quasi sempre non si apre.
|
|
1027
|
-
// File assente → `text` null: l'overlay si apre lo stesso, perché spawnare una
|
|
1028
|
-
// sessione non richiede il file (lo risolve `deck-run` per id).
|
|
1029
|
-
function openDetail() {
|
|
1030
|
-
const task = selectedTaskOr('⏎', 'aprire');
|
|
1031
|
-
if (!task)
|
|
1032
|
-
return;
|
|
1033
|
-
setSheet({
|
|
1034
|
-
id: task.id,
|
|
1035
|
-
// Il titolo del task file quando c'è (è l'H1, cioè la forma lunga), la
|
|
1036
|
-
// riga di tasks.md altrimenti: il detail non deve restare senza intestazione
|
|
1037
|
-
// solo perché il file manca.
|
|
1038
|
-
title: detail?.title || task.desc,
|
|
1039
|
-
text: loadTaskFileText(tasksDir, task.id),
|
|
1040
|
-
});
|
|
1041
|
-
setSheetTop(0);
|
|
1042
|
-
setSheetAction(0);
|
|
1043
|
-
setFind(null);
|
|
1044
|
-
setOccIdx(0);
|
|
1045
|
-
setNote('');
|
|
1046
|
-
setMode('detail');
|
|
1047
|
-
}
|
|
1048
|
-
function closeDetail() {
|
|
1049
|
-
setMode('normal');
|
|
1050
|
-
setSheet(null);
|
|
1051
|
-
setFind(null);
|
|
1052
|
-
}
|
|
1053
|
-
function scrollDetail(delta) {
|
|
1054
|
-
setSheetTop((t) => Math.max(0, Math.min(sheetMaxTop, t + delta)));
|
|
1055
|
-
}
|
|
1056
|
-
/** Modifica la query: l'insieme delle occorrenze cambia, quindi si riparte
|
|
1057
|
-
* dalla prima. Il movimento del caret NON passa di qui — sposta il cursore,
|
|
1058
|
-
* non i risultati. */
|
|
1059
|
-
function editFind(next) {
|
|
1060
|
-
setFind((f) => (f ? next(f) : f));
|
|
1061
|
-
setOccIdx(0);
|
|
1062
|
-
}
|
|
1063
|
-
function moveOcc(d) {
|
|
1064
|
-
const n = findRes.occ.length;
|
|
1065
|
-
if (n === 0)
|
|
1066
|
-
return;
|
|
1067
|
-
setOccIdx((i) => (Math.min(i, n - 1) + d + n) % n);
|
|
1068
|
-
}
|
|
1069
301
|
// T53 — apertura del modale nota sulla conversazione selezionata. Come
|
|
1070
302
|
// `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
|
|
1071
303
|
// quasi sempre correggere, e ripartire da vuoto costringerebbe a ridigitare
|
|
@@ -1098,58 +330,6 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1098
330
|
? `✎ nota su ${sid.slice(0, 8)}: "${cut(text, 40)}"`
|
|
1099
331
|
: `✎ nota rimossa da ${sid.slice(0, 8)}`);
|
|
1100
332
|
}
|
|
1101
|
-
// T57 — apertura del modale di assegnazione. La preselezione è la task a cui
|
|
1102
|
-
// la sessione è GIÀ legata (riassegnare è quasi sempre correggere, e vedere da
|
|
1103
|
-
// dove si parte è metà dell'informazione), altrimenti la prima della vista.
|
|
1104
|
-
// Mai `detach`: è l'azione distruttiva della lista, e preselezionarla
|
|
1105
|
-
// metterebbe un `⏎` battuto di slancio a un tasto dal cancellare un binding.
|
|
1106
|
-
function openAssign() {
|
|
1107
|
-
const sid = selSessionId;
|
|
1108
|
-
if (!sid)
|
|
1109
|
-
return;
|
|
1110
|
-
const bound = bindings.get(sid);
|
|
1111
|
-
setAssignSid(sid);
|
|
1112
|
-
setAssignFilter('');
|
|
1113
|
-
setAssignSel(bound && viewTasks.some((t) => t.id === bound) ? bound : viewTasks[0]?.id ?? null);
|
|
1114
|
-
setNote('');
|
|
1115
|
-
setMode('assign');
|
|
1116
|
-
}
|
|
1117
|
-
// Sposta la selezione del modale sulle righe (0 = detach, 1..N = task filtrate).
|
|
1118
|
-
function moveAssign(delta) {
|
|
1119
|
-
const at = assignRows.findIndex((t) => (t?.id ?? null) === assignSel);
|
|
1120
|
-
const next = Math.max(0, Math.min(assignRows.length - 1, (at < 0 ? 0 : at) + delta));
|
|
1121
|
-
setAssignSel(assignRows[next]?.id ?? null);
|
|
1122
|
-
}
|
|
1123
|
-
// T57 — ⏎ nel modale: riscrive il binding nel sidecar e ricarica subito.
|
|
1124
|
-
//
|
|
1125
|
-
// Il binding retroattivo governa il FUTURO della conversazione, non il suo
|
|
1126
|
-
// passato: il titolo della tab è stato deciso allo spawn da `claude --name` e
|
|
1127
|
-
// vive nel transcript, la `LOOM_TASK` di un processo già partito non si
|
|
1128
|
-
// reinietta. Cambia cosa fa il prossimo `⏎ resume`, che rilegge il binding dal
|
|
1129
|
-
// sidecar. La nota lo dice: senza, la promessa implicita è «ho spostato la
|
|
1130
|
-
// conversazione» e il titolo che non cambia sembra un bug.
|
|
1131
|
-
//
|
|
1132
|
-
// Dove atterra la selezione (D6): il pane task non si muove, quindi la
|
|
1133
|
-
// sessione appena assegnata esce dal gruppo contestuale → si scende alla riga
|
|
1134
|
-
// SUCCESSIVA, catturata PRIMA della riscrittura (dopo, la riga non c'è più).
|
|
1135
|
-
// Due eccezioni in cui invece resta dov'è, perché non sparisce affatto: una
|
|
1136
|
-
// pinnata (esente dal contesto) e un'assegnazione al parent già selezionato.
|
|
1137
|
-
function submitAssign() {
|
|
1138
|
-
const sid = assignSid;
|
|
1139
|
-
const target = assignSel;
|
|
1140
|
-
setMode('normal');
|
|
1141
|
-
setAssignFilter('');
|
|
1142
|
-
if (!sid)
|
|
1143
|
-
return;
|
|
1144
|
-
const stays = pinned.has(sid) || target === selectedTaskId;
|
|
1145
|
-
const next = stays ? sid : neighborId(sessionRows, sid);
|
|
1146
|
-
appendTaskBinding(cwd, sid, target ?? '');
|
|
1147
|
-
reloadSessions();
|
|
1148
|
-
setSelSessionId(next);
|
|
1149
|
-
setNote(target
|
|
1150
|
-
? `A ${sid.slice(0, 8)} → ${target} · vale dal prossimo ⏎ resume (titolo tab invariato)`
|
|
1151
|
-
: `A ${sid.slice(0, 8)} → spot · binding rimosso`);
|
|
1152
|
-
}
|
|
1153
333
|
// T41 — apertura dell'edit: la bozza parte dai valori ATTUALI della task, non
|
|
1154
334
|
// da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
|
|
1155
335
|
// stato dal suo glifo Prog; il progresso arbitrario dal campo `Progress` del
|
|
@@ -1241,7 +421,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1241
421
|
else
|
|
1242
422
|
setSel(paneTasks[next - META_ROWS]?.id ?? SPOT);
|
|
1243
423
|
}
|
|
1244
|
-
// T100 —
|
|
424
|
+
// T100 — `tab` naviga il catalogo viste del pane in focus. Il reset della
|
|
1245
425
|
// selezione è la regola letterale «prima riga in alto», senza eccezioni: sul
|
|
1246
426
|
// pane task è `ROW_ALL` (D2 preflight — le righe meta non si saltano, e il
|
|
1247
427
|
// parent delle sessioni che torna a `tutte` è un effetto accettato); sul pane
|
|
@@ -1261,512 +441,259 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1261
441
|
setNote(`vista sessioni: ${sessionView(next).label(sessionCounts, parentLabel)}`);
|
|
1262
442
|
}
|
|
1263
443
|
}
|
|
1264
|
-
//
|
|
1265
|
-
//
|
|
1266
|
-
//
|
|
1267
|
-
function
|
|
1268
|
-
const
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
}
|
|
1273
|
-
if (row.kind === 'session') {
|
|
1274
|
-
const bound = bindings.get(row.session.sessionId) ?? null;
|
|
1275
|
-
const child = spawnDeckResume(bound, cwd, row.session.sessionId, sessionNotes.get(row.session.sessionId));
|
|
1276
|
-
child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
|
|
1277
|
-
setNote(`⏎ resume ${row.session.sessionId.slice(0, 8)} → tab CC${bound ? ` (${bound})` : ' (spot)'}`);
|
|
1278
|
-
return;
|
|
1279
|
-
}
|
|
1280
|
-
// D8 — il reader si apre POSIZIONATO sull'occorrenza, non in cima. Il 94%
|
|
1281
|
-
// dei messaggi entra in una schermata e la differenza non si vede; è sul 6%
|
|
1282
|
-
// lungo (p99 ≈ 88 righe, max ≈ 1547) che aprire in cima costringerebbe a
|
|
1283
|
-
// rifare a mano la ricerca appena fatta — cioè proprio il lavoro che questa
|
|
1284
|
-
// feature esiste per evitare.
|
|
1285
|
-
const lines = wrapWithOffsets(row.hit.text, readerWidth);
|
|
1286
|
-
const cap = readerCapacity(rows);
|
|
1287
|
-
const matchLine = lines.findIndex((l) => l.end > row.hit.matchStart);
|
|
1288
|
-
const maxTop = Math.max(0, lines.length - cap);
|
|
1289
|
-
setReaderRow(row);
|
|
1290
|
-
setReaderTop(Math.max(0, Math.min(maxTop, Math.max(0, matchLine) - Math.floor(cap / 2))));
|
|
1291
|
-
setNote('');
|
|
1292
|
-
setMode('reader');
|
|
1293
|
-
}
|
|
1294
|
-
function scrollReader(delta) {
|
|
1295
|
-
setReaderTop((t) => Math.max(0, Math.min(readerMaxTop, t + delta)));
|
|
444
|
+
// T49 — resume di una conversazione in una nuova tab. Unico punto: lo chiamano
|
|
445
|
+
// il `⏎` della lista sessioni e quello sulla riga-sessione della ricerca, che
|
|
446
|
+
// devono restare la stessa azione.
|
|
447
|
+
function resumeSession(sessionId) {
|
|
448
|
+
const bound = bindings.get(sessionId) ?? null;
|
|
449
|
+
const child = spawnDeckResume(bound, cwd, sessionId, sessionNotes.get(sessionId));
|
|
450
|
+
child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
|
|
451
|
+
setNote(`⏎ resume ${sessionId.slice(0, 8)} → tab CC${bound ? ` (${bound})` : ' (spot)'}`);
|
|
1296
452
|
}
|
|
1297
|
-
//
|
|
1298
|
-
// vuota, nessun errore): è l'utente che ha chiuso ogni canale, non un guasto.
|
|
1299
|
-
function toggleKind(kind) {
|
|
1300
|
-
setSearchOpts((o) => ({ ...o, kinds: { ...o.kinds, [kind]: !o.kinds[kind] } }));
|
|
1301
|
-
}
|
|
1302
|
-
function editSearchField(fn) {
|
|
1303
|
-
// La nota racconta l'esito di un'AZIONE su una lista che, con l'eager, si
|
|
1304
|
-
// ricostruisce a ogni carattere: appena la query cambia è già scaduta.
|
|
1305
|
-
// Lasciarla lì la fa leggere come se descrivesse lo stato corrente — nello
|
|
1306
|
-
// specifico «nessuna occorrenza selezionata» sopra una lista con una riga
|
|
1307
|
-
// visibilmente selezionata, cioè una contraddizione a schermo.
|
|
1308
|
-
setNote('');
|
|
1309
|
-
if (searchField === 'hash')
|
|
1310
|
-
setSearchHash(fn);
|
|
1311
|
-
else
|
|
1312
|
-
setSearchQuery(fn);
|
|
1313
|
-
}
|
|
1314
|
-
// `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio —
|
|
1315
|
-
// o una raffica di tasti piu' veloce di una read — arriva come stringa unica,
|
|
1316
|
-
// byte di controllo compresi. Due conseguenze, entrambe verificate su pty:
|
|
1317
|
-
//
|
|
1318
|
-
// 1. I byte di controllo finiscono DENTRO il campo se non li si filtra. Non
|
|
1319
|
-
// si vedono, ma Ink li conta nella larghezza della riga e nessun match li
|
|
1320
|
-
// soddisfa -> la ricerca smette di trovare senza dire perche'. Le newline
|
|
1321
|
-
// diventano spazio invece di sparire: incollare due righe deve separare
|
|
1322
|
-
// le parole, non fonderle.
|
|
453
|
+
// T57 — ⏎ nel modale: riscrive il binding nel sidecar e ricarica subito.
|
|
1323
454
|
//
|
|
1324
|
-
//
|
|
1325
|
-
//
|
|
1326
|
-
//
|
|
1327
|
-
//
|
|
1328
|
-
//
|
|
455
|
+
// Il binding retroattivo governa il FUTURO della conversazione, non il suo
|
|
456
|
+
// passato: il titolo della tab è stato deciso allo spawn da `claude --name` e
|
|
457
|
+
// vive nel transcript, la `LOOM_TASK` di un processo già partito non si
|
|
458
|
+
// reinietta. Cambia cosa fa il prossimo `⏎ resume`, che rilegge il binding dal
|
|
459
|
+
// sidecar. La nota lo dice: senza, la promessa implicita è «ho spostato la
|
|
460
|
+
// conversazione» e il titolo che non cambia sembra un bug.
|
|
1329
461
|
//
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
462
|
+
// Dove atterra la selezione (D6): il pane task non si muove, quindi la
|
|
463
|
+
// sessione appena assegnata esce dal gruppo contestuale → si scende alla riga
|
|
464
|
+
// SUCCESSIVA, catturata PRIMA della riscrittura (dopo, la riga non c'è più).
|
|
465
|
+
// Due eccezioni in cui invece resta dov'è, perché non sparisce affatto: una
|
|
466
|
+
// pinnata (esente dal contesto) e un'assegnazione al parent già selezionato.
|
|
467
|
+
const assign = useAssignOverlay({
|
|
468
|
+
viewTasks,
|
|
469
|
+
rows,
|
|
470
|
+
hasNote: Boolean(note),
|
|
471
|
+
setMode,
|
|
472
|
+
setNote,
|
|
473
|
+
onSubmit: (sid, target) => {
|
|
474
|
+
const stays = pinned.has(sid) || target === selectedTaskId;
|
|
475
|
+
const next = stays ? sid : neighborId(sessionRows, sid);
|
|
476
|
+
appendTaskBinding(cwd, sid, target ?? '');
|
|
477
|
+
reloadSessions();
|
|
478
|
+
setSelSessionId(next);
|
|
479
|
+
setNote(target
|
|
480
|
+
? `A ${sid.slice(0, 8)} → ${target} · vale dal prossimo ⏎ resume (titolo tab invariato)`
|
|
481
|
+
: `A ${sid.slice(0, 8)} → spot · binding rimosso`);
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
const sheet = useSheetOverlay({
|
|
485
|
+
rows,
|
|
486
|
+
columns,
|
|
487
|
+
setMode,
|
|
488
|
+
setNote,
|
|
489
|
+
onAction: (id, kind, label) => spawnForTask(id, kind, label),
|
|
490
|
+
});
|
|
491
|
+
const search = useSearchOverlay({
|
|
492
|
+
sessions,
|
|
493
|
+
rows,
|
|
494
|
+
columns,
|
|
495
|
+
hasNote: Boolean(note),
|
|
496
|
+
setMode,
|
|
497
|
+
setNote,
|
|
498
|
+
onResume: (row) => resumeSession(row.session.sessionId),
|
|
499
|
+
});
|
|
500
|
+
function onCreateKey(input, key) {
|
|
501
|
+
if (key.escape) {
|
|
502
|
+
setMode('normal');
|
|
503
|
+
setDraft('');
|
|
504
|
+
setNote('C → create annullato');
|
|
505
|
+
}
|
|
506
|
+
else if (key.return) {
|
|
507
|
+
submitCreate();
|
|
508
|
+
}
|
|
509
|
+
else if (key.backspace || key.delete) {
|
|
510
|
+
setDraft((d) => d.slice(0, -1));
|
|
511
|
+
}
|
|
512
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
513
|
+
setDraft((d) => d + input);
|
|
1343
514
|
}
|
|
1344
|
-
if (add.hash)
|
|
1345
|
-
setSearchHash((v) => v + add.hash);
|
|
1346
|
-
if (add.query)
|
|
1347
|
-
setSearchQuery((v) => v + add.query);
|
|
1348
|
-
setSearchField(field);
|
|
1349
515
|
}
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
// e mentre è aperto il modale ricerca resta montato sotto con la sua
|
|
1356
|
-
// selezione intatta, pronto a riprendere il controllo su `esc`.
|
|
1357
|
-
if (mode === 'reader') {
|
|
1358
|
-
if (key.escape) {
|
|
1359
|
-
setMode('search');
|
|
1360
|
-
setReaderRow(null);
|
|
1361
|
-
}
|
|
1362
|
-
else if (key.upArrow) {
|
|
1363
|
-
scrollReader(-1);
|
|
1364
|
-
}
|
|
1365
|
-
else if (key.downArrow) {
|
|
1366
|
-
scrollReader(1);
|
|
1367
|
-
}
|
|
1368
|
-
else if (key.pageUp) {
|
|
1369
|
-
scrollReader(-readerCap);
|
|
1370
|
-
}
|
|
1371
|
-
else if (key.pageDown) {
|
|
1372
|
-
scrollReader(readerCap);
|
|
1373
|
-
}
|
|
1374
|
-
else if (input === 'g') {
|
|
1375
|
-
// Estremi su lettera e non su Home/End: Ink RICONOSCE quelle due (le
|
|
1376
|
-
// mappa a 'home'/'end' nel parser) ma NON le espone — `nonAlphanumericKeys`
|
|
1377
|
-
// azzera l'input e nessun flag le rappresenta, quindi arrivano
|
|
1378
|
-
// indistinguibili da qualunque tasto ignoto. Verificato su pty reale.
|
|
1379
|
-
// Nel reader non c'è input di testo, quindi le lettere sono libere.
|
|
1380
|
-
setReaderTop(0);
|
|
1381
|
-
}
|
|
1382
|
-
else if (input === 'G') {
|
|
1383
|
-
setReaderTop(readerMaxTop);
|
|
1384
|
-
}
|
|
1385
|
-
return;
|
|
516
|
+
function onNoteKey(input, key) {
|
|
517
|
+
if (key.escape) {
|
|
518
|
+
setMode('normal');
|
|
519
|
+
setNoteDraft('');
|
|
520
|
+
setNote('N → nota annullata');
|
|
1386
521
|
}
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
// tasti freccia o da Tab. `esc` chiude il modale, non il deck.
|
|
1390
|
-
if (mode === 'search') {
|
|
1391
|
-
if (key.escape) {
|
|
1392
|
-
setMode('normal');
|
|
1393
|
-
setNote('');
|
|
1394
|
-
return;
|
|
1395
|
-
}
|
|
1396
|
-
if (key.ctrl) {
|
|
1397
|
-
const flag = SEARCH_TOGGLE_KEYS[input];
|
|
1398
|
-
if (flag) {
|
|
1399
|
-
setSearchOpts((o) => ({ ...o, [flag]: !o[flag] }));
|
|
1400
|
-
return;
|
|
1401
|
-
}
|
|
1402
|
-
const kind = SEARCH_KIND_KEYS[input];
|
|
1403
|
-
if (kind)
|
|
1404
|
-
toggleKind(kind);
|
|
1405
|
-
return; // ogni altra combo ctrl (^F incluso: siamo già dentro) = no-op
|
|
1406
|
-
}
|
|
1407
|
-
if (key.tab) {
|
|
1408
|
-
setSearchField((f) => (f === 'hash' ? 'query' : 'hash'));
|
|
1409
|
-
}
|
|
1410
|
-
else if (key.upArrow) {
|
|
1411
|
-
setSearchSelKey((k) => moveRowSelection(searchRows, k, -1));
|
|
1412
|
-
}
|
|
1413
|
-
else if (key.downArrow) {
|
|
1414
|
-
setSearchSelKey((k) => moveRowSelection(searchRows, k, 1));
|
|
1415
|
-
}
|
|
1416
|
-
else if (key.pageUp) {
|
|
1417
|
-
setSearchSelKey((k) => moveRowSelection(searchRows, k, -Math.max(1, searchCap)));
|
|
1418
|
-
}
|
|
1419
|
-
else if (key.pageDown) {
|
|
1420
|
-
setSearchSelKey((k) => moveRowSelection(searchRows, k, Math.max(1, searchCap)));
|
|
1421
|
-
}
|
|
1422
|
-
else if (key.return) {
|
|
1423
|
-
submitSearchRow();
|
|
1424
|
-
}
|
|
1425
|
-
else if (key.backspace || key.delete) {
|
|
1426
|
-
editSearchField((s) => s.slice(0, -1));
|
|
1427
|
-
}
|
|
1428
|
-
else if (input && !key.meta) {
|
|
1429
|
-
typeIntoField(input);
|
|
1430
|
-
}
|
|
1431
|
-
return;
|
|
522
|
+
else if (key.return) {
|
|
523
|
+
submitNote();
|
|
1432
524
|
}
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
submitAssign();
|
|
1444
|
-
}
|
|
1445
|
-
else if (key.upArrow) {
|
|
1446
|
-
moveAssign(-1);
|
|
1447
|
-
}
|
|
1448
|
-
else if (key.downArrow) {
|
|
1449
|
-
moveAssign(1);
|
|
1450
|
-
}
|
|
1451
|
-
else if (key.pageUp) {
|
|
1452
|
-
moveAssign(-Math.max(1, assignCap));
|
|
1453
|
-
}
|
|
1454
|
-
else if (key.pageDown) {
|
|
1455
|
-
moveAssign(Math.max(1, assignCap));
|
|
1456
|
-
}
|
|
1457
|
-
else if (key.ctrl) {
|
|
1458
|
-
// `^U` svuota; ogni altra combo è no-op. Il backspace tenuto premuto
|
|
1459
|
-
// cancella un carattere per CHUNK letto da stdin, non per pressione
|
|
1460
|
-
// (T53): senza `^U` ripulire un filtro digitato di getto sarebbe lento
|
|
1461
|
-
// quanto riaprire il modale.
|
|
1462
|
-
if (input === 'u')
|
|
1463
|
-
setAssignFilter('');
|
|
1464
|
-
}
|
|
1465
|
-
else if (key.backspace || key.delete) {
|
|
1466
|
-
setAssignFilter((f) => f.slice(0, -1));
|
|
1467
|
-
}
|
|
1468
|
-
else if (input && !key.meta) {
|
|
1469
|
-
setAssignFilter((f) => f + sanitizeTyped(input));
|
|
1470
|
-
}
|
|
1471
|
-
return;
|
|
525
|
+
else if (key.ctrl && input === 'u') {
|
|
526
|
+
// Svuota il campo in un colpo. NON è una scorciatoia di comodo: il
|
|
527
|
+
// backspace tenuto premuto cancella UN carattere per CHUNK letto da
|
|
528
|
+
// stdin, non per pressione (`useInput` consegna il chunk, e per una
|
|
529
|
+
// raffica di DEL Ink alza `key.backspace` una volta sola) — misurato,
|
|
530
|
+
// 30 pressioni → 2 caratteri. Siccome «campo vuoto» qui è l'unico modo
|
|
531
|
+
// di CANCELLARE una nota, dipendere dal backspace renderebbe
|
|
532
|
+
// l'operazione praticamente non eseguibile. `^U` è il kill-line delle
|
|
533
|
+
// shell, quindi il gesto è già nelle dita di chi usa un terminale.
|
|
534
|
+
setNoteDraft('');
|
|
1472
535
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
if (mode === 'create') {
|
|
1476
|
-
if (key.escape) {
|
|
1477
|
-
setMode('normal');
|
|
1478
|
-
setDraft('');
|
|
1479
|
-
setNote('C → create annullato');
|
|
1480
|
-
}
|
|
1481
|
-
else if (key.return) {
|
|
1482
|
-
submitCreate();
|
|
1483
|
-
}
|
|
1484
|
-
else if (key.backspace || key.delete) {
|
|
1485
|
-
setDraft((d) => d.slice(0, -1));
|
|
1486
|
-
}
|
|
1487
|
-
else if (input && !key.ctrl && !key.meta) {
|
|
1488
|
-
setDraft((d) => d + input);
|
|
1489
|
-
}
|
|
1490
|
-
return;
|
|
536
|
+
else if (key.backspace || key.delete) {
|
|
537
|
+
setNoteDraft((d) => d.slice(0, -1));
|
|
1491
538
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
submitNote();
|
|
1502
|
-
}
|
|
1503
|
-
else if (key.ctrl && input === 'u') {
|
|
1504
|
-
// Svuota il campo in un colpo. NON è una scorciatoia di comodo: il
|
|
1505
|
-
// backspace tenuto premuto cancella UN carattere per CHUNK letto da
|
|
1506
|
-
// stdin, non per pressione (`useInput` consegna il chunk, e per una
|
|
1507
|
-
// raffica di DEL Ink alza `key.backspace` una volta sola) — misurato,
|
|
1508
|
-
// 30 pressioni → 2 caratteri. Siccome «campo vuoto» qui è l'unico modo
|
|
1509
|
-
// di CANCELLARE una nota, dipendere dal backspace renderebbe
|
|
1510
|
-
// l'operazione praticamente non eseguibile. `^U` è il kill-line delle
|
|
1511
|
-
// shell, quindi il gesto è già nelle dita di chi usa un terminale.
|
|
1512
|
-
setNoteDraft('');
|
|
1513
|
-
}
|
|
1514
|
-
else if (key.backspace || key.delete) {
|
|
1515
|
-
setNoteDraft((d) => d.slice(0, -1));
|
|
1516
|
-
}
|
|
1517
|
-
else if (input && !key.ctrl && !key.meta) {
|
|
1518
|
-
// Sanificazione dei byte di controllo, come `typeIntoField` della
|
|
1519
|
-
// ricerca: `useInput` consegna il CHUNK letto da stdin, quindi un
|
|
1520
|
-
// incollaggio porta dentro newline e control char. Invisibili a schermo
|
|
1521
|
-
// ma contati da Ink nella larghezza della riga — e qui finirebbero
|
|
1522
|
-
// scritti su disco, dove resterebbero a sporcare la riga per sempre.
|
|
1523
|
-
// Le newline diventano spazio: incollare due righe deve separare le
|
|
1524
|
-
// parole, non fonderle.
|
|
1525
|
-
setNoteDraft((d) => d + sanitizeTyped(input));
|
|
1526
|
-
}
|
|
1527
|
-
return;
|
|
539
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
540
|
+
// Sanificazione dei byte di controllo, come `typeIntoField` della
|
|
541
|
+
// ricerca: `useInput` consegna il CHUNK letto da stdin, quindi un
|
|
542
|
+
// incollaggio porta dentro newline e control char. Invisibili a schermo
|
|
543
|
+
// ma contati da Ink nella larghezza della riga — e qui finirebbero
|
|
544
|
+
// scritti su disco, dove resterebbero a sporcare la riga per sempre.
|
|
545
|
+
// Le newline diventano spazio: incollare due righe deve separare le
|
|
546
|
+
// parole, non fonderle.
|
|
547
|
+
setNoteDraft((d) => d + sanitizeTyped(input));
|
|
1528
548
|
}
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
closeViewModal(true);
|
|
1535
|
-
setNote('S → sort annullato');
|
|
1536
|
-
}
|
|
1537
|
-
else if (key.return) {
|
|
1538
|
-
closeViewModal(false);
|
|
1539
|
-
setNote(`S → sort: ${describeSort(view.sort)}`);
|
|
1540
|
-
}
|
|
1541
|
-
else if (input) {
|
|
1542
|
-
// useInput consegna il CHUNK letto da stdin, non un tasto: digitando
|
|
1543
|
-
// veloce (o incollando) `ppi` arriva come stringa unica. Si cicla su
|
|
1544
|
-
// ogni carattere, così la chain esce identica a battitura lenta.
|
|
1545
|
-
const keys = [...input].map((ch) => SORT_TASTI[ch]).filter(Boolean);
|
|
1546
|
-
if (keys.length > 0) {
|
|
1547
|
-
setView((v) => ({ ...v, sort: keys.reduce(cycleSort, v.sort) }));
|
|
1548
|
-
}
|
|
1549
|
-
}
|
|
1550
|
-
return;
|
|
549
|
+
}
|
|
550
|
+
function onSortKey(input, key) {
|
|
551
|
+
if (key.escape) {
|
|
552
|
+
closeViewModal(true);
|
|
553
|
+
setNote('S → sort annullato');
|
|
1551
554
|
}
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
const rowLen = (r) => (r === 0 ? PRI_ENTRIES.length : PROG_ENTRIES.length);
|
|
1556
|
-
if (key.escape) {
|
|
1557
|
-
closeViewModal(true);
|
|
1558
|
-
setNote('F → filtri annullati');
|
|
1559
|
-
}
|
|
1560
|
-
else if (key.return) {
|
|
1561
|
-
closeViewModal(false);
|
|
1562
|
-
setNote(hiddenTasks > 0 ? `F → ${hiddenTasks} task nascoste` : 'F → nessun filtro attivo');
|
|
1563
|
-
}
|
|
1564
|
-
else if (key.upArrow || key.downArrow) {
|
|
1565
|
-
setFilterCursor((c) => {
|
|
1566
|
-
const row = c.row === 0 ? 1 : 0;
|
|
1567
|
-
return { row, col: Math.min(c.col, rowLen(row) - 1) };
|
|
1568
|
-
});
|
|
1569
|
-
}
|
|
1570
|
-
else if (key.leftArrow) {
|
|
1571
|
-
setFilterCursor((c) => ({ ...c, col: Math.max(0, c.col - 1) }));
|
|
1572
|
-
}
|
|
1573
|
-
else if (key.rightArrow) {
|
|
1574
|
-
setFilterCursor((c) => ({ ...c, col: Math.min(rowLen(c.row) - 1, c.col + 1) }));
|
|
1575
|
-
}
|
|
1576
|
-
else if (input === ' ') {
|
|
1577
|
-
const { row, col } = filterCursor;
|
|
1578
|
-
setView((v) => row === 0
|
|
1579
|
-
? { ...v, hiddenPri: toggleHidden(v.hiddenPri, PRI_ENTRIES[col].name) }
|
|
1580
|
-
: { ...v, hiddenProg: toggleHidden(v.hiddenProg, PROG_ENTRIES[col].name) });
|
|
1581
|
-
}
|
|
1582
|
-
return;
|
|
555
|
+
else if (key.return) {
|
|
556
|
+
closeViewModal(false);
|
|
557
|
+
setNote(`S → sort: ${describeSort(view.sort)}`);
|
|
1583
558
|
}
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
if (
|
|
1590
|
-
|
|
1591
|
-
setEdit(null);
|
|
1592
|
-
setNote('E → edit annullato');
|
|
1593
|
-
}
|
|
1594
|
-
else if (key.return) {
|
|
1595
|
-
submitEdit();
|
|
1596
|
-
}
|
|
1597
|
-
else if (key.upArrow || key.downArrow) {
|
|
1598
|
-
const next = ((editRow + EDIT_ROWS + (key.upArrow ? -1 : 1)) % EDIT_ROWS);
|
|
1599
|
-
setEditRow(next);
|
|
1600
|
-
// Il caret segue la riga attiva e atterra in CODA al nuovo campo: è la
|
|
1601
|
-
// posizione da cui si continua a scrivere, ed è anche l'unica che non
|
|
1602
|
-
// dipende da dove stava il cursore nel campo precedente.
|
|
1603
|
-
setEdit((e) => (e && isTextRow(next) ? { ...e, caret: cpLen(e[editField(next)]) } : e));
|
|
1604
|
-
}
|
|
1605
|
-
else if ((key.leftArrow || key.rightArrow) && !isTextRow(editRow)) {
|
|
1606
|
-
const d = key.leftArrow ? -1 : 1;
|
|
1607
|
-
// Scorrimento CICLICO (wrap) e non clampato: le liste sono di 3-4 voci,
|
|
1608
|
-
// arrivare in fondo e ripartire costa meno di invertire direzione.
|
|
1609
|
-
if (editRow === 0) {
|
|
1610
|
-
setEdit((e) => e ? { ...e, pri: EDIT_PRI[(EDIT_PRI.indexOf(e.pri) + d + EDIT_PRI.length) % EDIT_PRI.length] } : e);
|
|
1611
|
-
}
|
|
1612
|
-
else if (editRow === 1) {
|
|
1613
|
-
setEdit((e) => e
|
|
1614
|
-
? { ...e, prog: EDIT_PROG[(EDIT_PROG.indexOf(e.prog) + d + EDIT_PROG.length) % EDIT_PROG.length] }
|
|
1615
|
-
: e);
|
|
1616
|
-
}
|
|
1617
|
-
}
|
|
1618
|
-
else if (isTextRow(editRow)) {
|
|
1619
|
-
// Un solo ramo per i due campi di testo, la riga sceglie la chiave:
|
|
1620
|
-
// duplicarlo significherebbe tenere allineate a mano due copie della
|
|
1621
|
-
// stessa grammatica di input a ogni tasto aggiunto.
|
|
1622
|
-
const field = editField(editRow);
|
|
1623
|
-
if (key.ctrl) {
|
|
1624
|
-
// T54 — ramo CTRL ANTEPOSTO a quelli su carattere, come in modalità
|
|
1625
|
-
// normale: `^A` e `a` arrivano con lo stesso `input`, quindi senza
|
|
1626
|
-
// questa precedenza il `^A` finirebbe dentro il testo.
|
|
1627
|
-
//
|
|
1628
|
-
// `^A`/`^E` (convenzione readline) perché `Home`/`End` NON sono
|
|
1629
|
-
// esposte da `useInput`: arrivano come input vuoto, indistinguibili
|
|
1630
|
-
// da qualunque altro tasto senza nome.
|
|
1631
|
-
//
|
|
1632
|
-
// `^D` è il delete-forward, e anche qui il motivo è un limite di Ink:
|
|
1633
|
-
// il tasto Backspace fisico manda `\x7f` e il tasto Canc manda
|
|
1634
|
-
// `\x1b[3~`, ma `parseKeypress` li battezza ENTRAMBI `delete` e
|
|
1635
|
-
// svuota `input` — a valle sono lo stesso evento. `key.delete` va
|
|
1636
|
-
// quindi al backspace (il tasto che si usa davvero) e la
|
|
1637
|
-
// cancellazione in avanti prende il suo tasto readline.
|
|
1638
|
-
if (input === 'a')
|
|
1639
|
-
setEdit((e) => (e ? { ...e, caret: 0 } : e));
|
|
1640
|
-
else if (input === 'e')
|
|
1641
|
-
setEdit((e) => (e ? { ...e, caret: cpLen(e[field]) } : e));
|
|
1642
|
-
else if (input === 'd')
|
|
1643
|
-
setEdit((e) => (e ? { ...e, [field]: removeAt(e[field], e.caret) } : e));
|
|
1644
|
-
}
|
|
1645
|
-
else if (key.leftArrow || key.rightArrow) {
|
|
1646
|
-
// CLAMP agli estremi, non wrap: a inizio campo `←` non deve saltare in
|
|
1647
|
-
// fondo. Le liste di valori (righe 0/1) ciclano perché sono 3-4 voci;
|
|
1648
|
-
// un testo no — il salto sarebbe indistinguibile da uno sfarfallio.
|
|
1649
|
-
const d = key.leftArrow ? -1 : 1;
|
|
1650
|
-
setEdit((e) => e ? { ...e, caret: Math.max(0, Math.min(cpLen(e[field]), e.caret + d)) } : e);
|
|
1651
|
-
}
|
|
1652
|
-
else if (key.backspace || key.delete) {
|
|
1653
|
-
setEdit((e) => e ? { ...e, [field]: removeAt(e[field], e.caret - 1), caret: Math.max(0, e.caret - 1) } : e);
|
|
1654
|
-
}
|
|
1655
|
-
else if (input && !key.meta) {
|
|
1656
|
-
// `sanitizeTyped`: `useInput` consegna il CHUNK di stdin, quindi un
|
|
1657
|
-
// incollaggio porta dentro newline e byte di controllo — invisibili
|
|
1658
|
-
// nel campo ma contati da Ink nella larghezza della riga, e destinati
|
|
1659
|
-
// a finire tali e quali dentro tasks.md. Ed è per lo stesso motivo che
|
|
1660
|
-
// il caret avanza della LUNGHEZZA del chunk, non di uno: un incollaggio
|
|
1661
|
-
// entra tutto insieme.
|
|
1662
|
-
const ins = sanitizeTyped(input);
|
|
1663
|
-
setEdit((e) => e ? { ...e, [field]: insertAt(e[field], e.caret, ins), caret: e.caret + cpLen(ins) } : e);
|
|
1664
|
-
}
|
|
559
|
+
else if (input) {
|
|
560
|
+
// useInput consegna il CHUNK letto da stdin, non un tasto: digitando
|
|
561
|
+
// veloce (o incollando) `ppi` arriva come stringa unica. Si cicla su
|
|
562
|
+
// ogni carattere, così la chain esce identica a battitura lenta.
|
|
563
|
+
const keys = [...input].map((ch) => SORT_TASTI[ch]).filter(Boolean);
|
|
564
|
+
if (keys.length > 0) {
|
|
565
|
+
setView((v) => ({ ...v, sort: keys.reduce(cycleSort, v.sort) }));
|
|
1665
566
|
}
|
|
1666
|
-
return;
|
|
1667
567
|
}
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
if (
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
// Nessun reset di `sel`: la selezione della lista non è mai stata
|
|
1727
|
-
// toccata, quindi si ritrova esattamente dov'era.
|
|
1728
|
-
if (find)
|
|
1729
|
-
setFind(null);
|
|
1730
|
-
else
|
|
1731
|
-
closeDetail();
|
|
568
|
+
}
|
|
569
|
+
function onFilterKey(input, key) {
|
|
570
|
+
const rowLen = (r) => (r === 0 ? PRI_ENTRIES.length : PROG_ENTRIES.length);
|
|
571
|
+
if (key.escape) {
|
|
572
|
+
closeViewModal(true);
|
|
573
|
+
setNote('F → filtri annullati');
|
|
574
|
+
}
|
|
575
|
+
else if (key.return) {
|
|
576
|
+
closeViewModal(false);
|
|
577
|
+
setNote(hiddenTasks > 0 ? `F → ${hiddenTasks} task nascoste` : 'F → nessun filtro attivo');
|
|
578
|
+
}
|
|
579
|
+
else if (key.upArrow || key.downArrow) {
|
|
580
|
+
setFilterCursor((c) => {
|
|
581
|
+
const row = c.row === 0 ? 1 : 0;
|
|
582
|
+
return { row, col: Math.min(c.col, rowLen(row) - 1) };
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
else if (key.leftArrow) {
|
|
586
|
+
setFilterCursor((c) => ({ ...c, col: Math.max(0, c.col - 1) }));
|
|
587
|
+
}
|
|
588
|
+
else if (key.rightArrow) {
|
|
589
|
+
setFilterCursor((c) => ({ ...c, col: Math.min(rowLen(c.row) - 1, c.col + 1) }));
|
|
590
|
+
}
|
|
591
|
+
else if (input === ' ') {
|
|
592
|
+
const { row, col } = filterCursor;
|
|
593
|
+
setView((v) => row === 0
|
|
594
|
+
? { ...v, hiddenPri: toggleHidden(v.hiddenPri, PRI_ENTRIES[col].name) }
|
|
595
|
+
: { ...v, hiddenProg: toggleHidden(v.hiddenProg, PROG_ENTRIES[col].name) });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function onEditKey(input, key) {
|
|
599
|
+
if (key.escape) {
|
|
600
|
+
setMode('normal');
|
|
601
|
+
setEdit(null);
|
|
602
|
+
setNote('E → edit annullato');
|
|
603
|
+
}
|
|
604
|
+
else if (key.return) {
|
|
605
|
+
submitEdit();
|
|
606
|
+
}
|
|
607
|
+
else if (key.upArrow || key.downArrow) {
|
|
608
|
+
const next = ((editRow + EDIT_ROWS + (key.upArrow ? -1 : 1)) % EDIT_ROWS);
|
|
609
|
+
setEditRow(next);
|
|
610
|
+
// Il caret segue la riga attiva e atterra in CODA al nuovo campo: è la
|
|
611
|
+
// posizione da cui si continua a scrivere, ed è anche l'unica che non
|
|
612
|
+
// dipende da dove stava il cursore nel campo precedente.
|
|
613
|
+
setEdit((e) => (e && isTextRow(next) ? { ...e, caret: cpLen(e[editField(next)]) } : e));
|
|
614
|
+
}
|
|
615
|
+
else if ((key.leftArrow || key.rightArrow) && !isTextRow(editRow)) {
|
|
616
|
+
const d = key.leftArrow ? -1 : 1;
|
|
617
|
+
// Scorrimento CICLICO (wrap) e non clampato: le liste sono di 3-4 voci,
|
|
618
|
+
// arrivare in fondo e ripartire costa meno di invertire direzione.
|
|
619
|
+
if (editRow === 0) {
|
|
620
|
+
setEdit((e) => e ? { ...e, pri: EDIT_PRI[(EDIT_PRI.indexOf(e.pri) + d + EDIT_PRI.length) % EDIT_PRI.length] } : e);
|
|
621
|
+
}
|
|
622
|
+
else if (editRow === 1) {
|
|
623
|
+
setEdit((e) => e
|
|
624
|
+
? { ...e, prog: EDIT_PROG[(EDIT_PROG.indexOf(e.prog) + d + EDIT_PROG.length) % EDIT_PROG.length] }
|
|
625
|
+
: e);
|
|
1732
626
|
}
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
627
|
+
}
|
|
628
|
+
else if (isTextRow(editRow)) {
|
|
629
|
+
// Un solo ramo per i due campi di testo, la riga sceglie la chiave:
|
|
630
|
+
// duplicarlo significherebbe tenere allineate a mano due copie della
|
|
631
|
+
// stessa grammatica di input a ogni tasto aggiunto.
|
|
632
|
+
const field = editField(editRow);
|
|
633
|
+
if (key.ctrl) {
|
|
634
|
+
// T54 — ramo CTRL ANTEPOSTO a quelli su carattere, come in modalità
|
|
635
|
+
// normale: `^A` e `a` arrivano con lo stesso `input`, quindi senza
|
|
636
|
+
// questa precedenza il `^A` finirebbe dentro il testo.
|
|
637
|
+
//
|
|
638
|
+
// `^A`/`^E` (convenzione readline) perché `Home`/`End` NON sono
|
|
639
|
+
// esposte da `useInput`: arrivano come input vuoto, indistinguibili
|
|
640
|
+
// da qualunque altro tasto senza nome.
|
|
641
|
+
//
|
|
642
|
+
// `^D` è il delete-forward, e anche qui il motivo è un limite di Ink:
|
|
643
|
+
// il tasto Backspace fisico manda `\x7f` e il tasto Canc manda
|
|
644
|
+
// `\x1b[3~`, ma `parseKeypress` li battezza ENTRAMBI `delete` e
|
|
645
|
+
// svuota `input` — a valle sono lo stesso evento. `key.delete` va
|
|
646
|
+
// quindi al backspace (il tasto che si usa davvero) e la
|
|
647
|
+
// cancellazione in avanti prende il suo tasto readline.
|
|
648
|
+
if (input === 'a')
|
|
649
|
+
setEdit((e) => (e ? { ...e, caret: 0 } : e));
|
|
650
|
+
else if (input === 'e')
|
|
651
|
+
setEdit((e) => (e ? { ...e, caret: cpLen(e[field]) } : e));
|
|
652
|
+
else if (input === 'd')
|
|
653
|
+
setEdit((e) => (e ? { ...e, [field]: removeAt(e[field], e.caret) } : e));
|
|
1742
654
|
}
|
|
1743
655
|
else if (key.leftArrow || key.rightArrow) {
|
|
1744
|
-
//
|
|
1745
|
-
//
|
|
656
|
+
// CLAMP agli estremi, non wrap: a inizio campo `←` non deve saltare in
|
|
657
|
+
// fondo. Le liste di valori (righe 0/1) ciclano perché sono 3-4 voci;
|
|
658
|
+
// un testo no — il salto sarebbe indistinguibile da uno sfarfallio.
|
|
1746
659
|
const d = key.leftArrow ? -1 : 1;
|
|
1747
|
-
|
|
1748
|
-
}
|
|
1749
|
-
else if (key.upArrow) {
|
|
1750
|
-
scrollDetail(-1);
|
|
1751
|
-
}
|
|
1752
|
-
else if (key.downArrow) {
|
|
1753
|
-
scrollDetail(1);
|
|
1754
|
-
}
|
|
1755
|
-
else if (key.pageUp) {
|
|
1756
|
-
scrollDetail(-sheetCap);
|
|
1757
|
-
}
|
|
1758
|
-
else if (key.pageDown) {
|
|
1759
|
-
scrollDetail(sheetCap);
|
|
660
|
+
setEdit((e) => e ? { ...e, caret: Math.max(0, Math.min(cpLen(e[field]), e.caret + d)) } : e);
|
|
1760
661
|
}
|
|
1761
|
-
else if (
|
|
1762
|
-
|
|
1763
|
-
// `Home`/`End` ma non le espone, e qui non c'è input di testo che
|
|
1764
|
-
// contenda le lettere.
|
|
1765
|
-
setSheetTop(0);
|
|
662
|
+
else if (key.backspace || key.delete) {
|
|
663
|
+
setEdit((e) => e ? { ...e, [field]: removeAt(e[field], e.caret - 1), caret: Math.max(0, e.caret - 1) } : e);
|
|
1766
664
|
}
|
|
1767
|
-
else if (input
|
|
1768
|
-
|
|
665
|
+
else if (input && !key.meta) {
|
|
666
|
+
// `sanitizeTyped`: `useInput` consegna il CHUNK di stdin, quindi un
|
|
667
|
+
// incollaggio porta dentro newline e byte di controllo — invisibili
|
|
668
|
+
// nel campo ma contati da Ink nella larghezza della riga, e destinati
|
|
669
|
+
// a finire tali e quali dentro tasks.md. Ed è per lo stesso motivo che
|
|
670
|
+
// il caret avanza della LUNGHEZZA del chunk, non di uno: un incollaggio
|
|
671
|
+
// entra tutto insieme.
|
|
672
|
+
const ins = sanitizeTyped(input);
|
|
673
|
+
setEdit((e) => e ? { ...e, [field]: insertAt(e[field], e.caret, ins), caret: e.caret + cpLen(ins) } : e);
|
|
1769
674
|
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// Il dispatch consulta il CATALOGO (`input-modes.ts`), non l'ordine di una
|
|
678
|
+
// catena di `if`. `MODE_KEYS` è il custode: essendo un `Record` su
|
|
679
|
+
// `CapturingMode`, un modo nuovo senza handler non compila.
|
|
680
|
+
const MODE_KEYS = {
|
|
681
|
+
detail: sheet.onKey,
|
|
682
|
+
reader: search.onReaderKey,
|
|
683
|
+
search: search.onSearchKey,
|
|
684
|
+
assign: assign.onKey,
|
|
685
|
+
create: onCreateKey,
|
|
686
|
+
note: onNoteKey,
|
|
687
|
+
sort: onSortKey,
|
|
688
|
+
filter: onFilterKey,
|
|
689
|
+
edit: onEditKey,
|
|
690
|
+
};
|
|
691
|
+
useInput((input, key) => {
|
|
692
|
+
// Un modo capturing consuma TUTTO — acceleratori globali compresi — e le
|
|
693
|
+
// deroghe se le gestisce da sé (`CTRL_DEROGATIONS`). Da qui in giù si è
|
|
694
|
+
// quindi in `normal`, l'unico modo che cede ai `key.ctrl`.
|
|
695
|
+
if (captures(mode)) {
|
|
696
|
+
MODE_KEYS[mode](input, key);
|
|
1770
697
|
return;
|
|
1771
698
|
}
|
|
1772
699
|
// T52/D1 — il ramo CTRL sta PRIMA di quelli su lettera nuda e li chiude
|
|
@@ -1794,16 +721,22 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1794
721
|
return;
|
|
1795
722
|
}
|
|
1796
723
|
if (key.tab) {
|
|
1797
|
-
//
|
|
1798
|
-
//
|
|
1799
|
-
//
|
|
1800
|
-
//
|
|
1801
|
-
//
|
|
1802
|
-
//
|
|
1803
|
-
|
|
724
|
+
// `tab` cicla la VISTA del pane a fuoco, `←→` spostano il focus fra i due
|
|
725
|
+
// pane: il criterio dell'assegnazione è spaziale. Una freccia orizzontale
|
|
726
|
+
// porta con sé una direzione e i pane sono affiancati (task a sinistra,
|
|
727
|
+
// sessioni a destra), quindi il tasto NOMINA il pane invece di limitarsi
|
|
728
|
+
// a scambiarlo; un catalogo ciclico non ha un verso da rispettare, e un
|
|
729
|
+
// tasto solo gli basta. `shift+tab` (backtab, `[Z`) scorre a rovescio — il
|
|
730
|
+
// verso che un tasto singolo non esprime sta nel modificatore, e su cinque
|
|
731
|
+
// voci risparmia quattro pressioni.
|
|
732
|
+
cycleView(key.shift ? -1 : 1);
|
|
1804
733
|
}
|
|
1805
734
|
else if (key.leftArrow || key.rightArrow) {
|
|
1806
|
-
|
|
735
|
+
// Binding ASSOLUTO, non toggle: `←` porta sempre sui task e `→` sempre
|
|
736
|
+
// sulle sessioni, quindi ripremere lo stesso tasto non riporta indietro.
|
|
737
|
+
// È ciò che lo rende spaziale — la direzione indica una destinazione, e
|
|
738
|
+
// con due soli pane un toggle sarebbe indistinguibile solo per caso.
|
|
739
|
+
setFocus(key.leftArrow ? 'tasks' : 'sessions');
|
|
1807
740
|
}
|
|
1808
741
|
else if (key.upArrow) {
|
|
1809
742
|
if (focus === 'tasks')
|
|
@@ -1825,7 +758,19 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1825
758
|
// più battuto non è il posto dove inchiodare una scelta di prompt — le
|
|
1826
759
|
// shortcut CTRL restano per chi sa già cosa vuole, `⏎` apre il ventaglio.
|
|
1827
760
|
// Lo spawn a mani nude di prima è `open`, cioè `⏎ ⏎` (è il focus iniziale).
|
|
1828
|
-
|
|
761
|
+
{
|
|
762
|
+
const task = selectedTaskOr('⏎', 'aprire');
|
|
763
|
+
if (task) {
|
|
764
|
+
sheet.open({
|
|
765
|
+
id: task.id,
|
|
766
|
+
// Il titolo del task file quando c'è (è l'H1, cioè la forma
|
|
767
|
+
// lunga), la riga di tasks.md altrimenti: il detail non deve
|
|
768
|
+
// restare senza intestazione solo perché il file manca.
|
|
769
|
+
title: detail?.title || task.desc,
|
|
770
|
+
text: loadTaskFileText(tasksDir, task.id),
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
1829
774
|
}
|
|
1830
775
|
else {
|
|
1831
776
|
// T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
|
|
@@ -1835,10 +780,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1835
780
|
setNote(selSessionId ? 'pin stale: transcript non più presente' : 'nessuna sessione da riprendere');
|
|
1836
781
|
}
|
|
1837
782
|
else {
|
|
1838
|
-
|
|
1839
|
-
const child = spawnDeckResume(bound, cwd, s.sessionId, sessionNotes.get(s.sessionId));
|
|
1840
|
-
child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
|
|
1841
|
-
setNote(`⏎ resume ${s.sessionId.slice(0, 8)} → tab CC${bound ? ` (${bound})` : ' (spot)'}`);
|
|
783
|
+
resumeSession(s.sessionId);
|
|
1842
784
|
}
|
|
1843
785
|
}
|
|
1844
786
|
}
|
|
@@ -1880,7 +822,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1880
822
|
// Vive solo sul pane sessioni: il fork ha per oggetto una conversazione,
|
|
1881
823
|
// e senza focus lì non ce n'è una selezionata su cui agire.
|
|
1882
824
|
if (focus !== 'sessions') {
|
|
1883
|
-
setNote('f → fork: seleziona una sessione (
|
|
825
|
+
setNote('f → fork: seleziona una sessione (→ per il pane)');
|
|
1884
826
|
}
|
|
1885
827
|
else {
|
|
1886
828
|
const s = selSessionObj;
|
|
@@ -1912,7 +854,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1912
854
|
// pinnata STALE (l'unico modo di spinnarla). Scrive il sidecar e ricarica
|
|
1913
855
|
// subito, senza attendere il tick del poll.
|
|
1914
856
|
if (focus !== 'sessions') {
|
|
1915
|
-
setNote('p → pin: seleziona una sessione (
|
|
857
|
+
setNote('p → pin: seleziona una sessione (→ per il pane)');
|
|
1916
858
|
}
|
|
1917
859
|
else if (!selSessionId) {
|
|
1918
860
|
setNote('p → nessuna sessione da pinnare');
|
|
@@ -1940,7 +882,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1940
882
|
// su una pinnata STALE, perché annotare «questa non c'è più, era X» è
|
|
1941
883
|
// proprio il caso in cui una nota serve.
|
|
1942
884
|
if (focus !== 'sessions') {
|
|
1943
|
-
setNote('N → nota: seleziona una sessione (
|
|
885
|
+
setNote('N → nota: seleziona una sessione (→ per il pane)');
|
|
1944
886
|
}
|
|
1945
887
|
else if (!selSessionId) {
|
|
1946
888
|
setNote('N → nessuna sessione da annotare');
|
|
@@ -1958,13 +900,14 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1958
900
|
// il binding è nostro, il transcript è di CC — riassegnare una
|
|
1959
901
|
// conversazione il cui transcript non c'è più resta legittimo.
|
|
1960
902
|
if (focus !== 'sessions') {
|
|
1961
|
-
setNote('A → assegna: seleziona una sessione (
|
|
903
|
+
setNote('A → assegna: seleziona una sessione (→ per il pane)');
|
|
1962
904
|
}
|
|
1963
905
|
else if (!selSessionId) {
|
|
1964
906
|
setNote('A → nessuna sessione da assegnare');
|
|
1965
907
|
}
|
|
1966
908
|
else {
|
|
1967
|
-
|
|
909
|
+
if (selSessionId)
|
|
910
|
+
assign.open(selSessionId, bindings.get(selSessionId));
|
|
1968
911
|
}
|
|
1969
912
|
}
|
|
1970
913
|
else if (input === 't') {
|
|
@@ -2051,32 +994,32 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
2051
994
|
// visibile, quindi va RIPETUTA nel titolo — senza, non si sa più su cosa si
|
|
2052
995
|
// sta agendo.
|
|
2053
996
|
if (mode === 'assign') {
|
|
2054
|
-
if (isCompact(
|
|
997
|
+
if (isCompact(assign.capacity)) {
|
|
2055
998
|
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 assegna \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7 esc annulla"] })] }));
|
|
2056
999
|
}
|
|
2057
|
-
const at =
|
|
2058
|
-
const win = windowRange(
|
|
2059
|
-
const s =
|
|
1000
|
+
const at = assign.list.findIndex((t) => (t?.id ?? null) === assign.sel);
|
|
1001
|
+
const win = windowRange(assign.list.length, at, assign.capacity);
|
|
1002
|
+
const s = assign.sid ? sessions.find((x) => x.sessionId === assign.sid) ?? null : null;
|
|
2060
1003
|
// Etichetta della conversazione: la nota umana se c'è (è il nome con cui la
|
|
2061
1004
|
// riconosci), altrimenti la stessa derivazione della ricerca. Su una pinnata
|
|
2062
1005
|
// stale non resta nulla: il titolo si accontenta dell'hash.
|
|
2063
|
-
const label = (
|
|
2064
|
-
(s ? conversationLabel(s, projectCore,
|
|
2065
|
-
return (_jsx(AssignScreen, { sessionId:
|
|
1006
|
+
const label = (assign.sid ? sessionNotes.get(assign.sid) : '') ||
|
|
1007
|
+
(s ? conversationLabel(s, projectCore, assign.sid ? bindings.get(assign.sid) : undefined) : '');
|
|
1008
|
+
return (_jsx(AssignScreen, { sessionId: assign.sid ?? '', label: label, current: assign.sid ? bindings.get(assign.sid) ?? null : null, filter: assign.filter, rows: assign.list.slice(win.start, win.end), selected: assign.sel, matched: assign.list.length - 1, hidden: hiddenTasks, above: win.start, below: assign.list.length - win.end, childCount: childCount, columns: columns, note: note }));
|
|
2066
1009
|
}
|
|
2067
1010
|
// ── T66 · detail della task ─────────────────────────────────────────────
|
|
2068
1011
|
// Quarta schermata sostitutiva, stessa ragione delle altre tre: un task file
|
|
2069
1012
|
// non entra in un box sopra i due pane. Il budget dei pane non viene nemmeno
|
|
2070
1013
|
// calcolato — il render esce di qui prima.
|
|
2071
|
-
if (mode === 'detail' && sheet) {
|
|
2072
|
-
if (isCompact(
|
|
2073
|
-
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", sheet.id, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7 esc chiude"] })] }));
|
|
1014
|
+
if (mode === 'detail' && sheet.sheet) {
|
|
1015
|
+
if (isCompact(sheet.capacity)) {
|
|
1016
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", sheet.sheet.id, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7 esc chiude"] })] }));
|
|
2074
1017
|
}
|
|
2075
1018
|
// Niente `windowRange`: quella centra la finestra su una selezione, qui la
|
|
2076
1019
|
// posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
|
|
2077
1020
|
// può accorciare il testo sotto uno scroll già dato.
|
|
2078
|
-
const start = Math.min(
|
|
2079
|
-
return (_jsx(DetailScreen, { id: sheet.id, title: sheet.title, missing: sheet.text === null, lines:
|
|
1021
|
+
const start = Math.min(sheet.top, sheet.maxTop);
|
|
1022
|
+
return (_jsx(DetailScreen, { id: sheet.sheet.id, title: sheet.sheet.title, missing: sheet.sheet.text === null, lines: sheet.lines.slice(start, start + sheet.capacity), spans: sheet.doc?.spans ?? [], top: start, total: sheet.lines.length, capacity: sheet.capacity, action: sheet.action, columns: columns, find: sheet.find, occ: sheet.findRes.occ, occCur: sheet.occCur }));
|
|
2080
1023
|
}
|
|
2081
1024
|
// ── T52 · schermate sostitutive ─────────────────────────────────────────
|
|
2082
1025
|
// Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
|
|
@@ -2085,42 +1028,42 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
2085
1028
|
// serve nemmeno calcolarlo, e la loro altezza la distribuiscono
|
|
2086
1029
|
// `searchListCapacity` / `readerCapacity`.
|
|
2087
1030
|
if (mode === 'search' || mode === 'reader') {
|
|
2088
|
-
const hit = mode === 'reader' && readerRow?.kind === 'hit' ? readerRow.hit : null;
|
|
1031
|
+
const hit = mode === 'reader' && search.readerRow?.kind === 'hit' ? search.readerRow.hit : null;
|
|
2089
1032
|
// Terminale sotto la cornice: riga singola invece del box, per lo stesso
|
|
2090
1033
|
// motivo del `budget.compact` del deck — un frame più alto di `rows` fa
|
|
2091
1034
|
// pulire lo schermo a Ink a ogni redraw, e il poll lo versa nello scrollback.
|
|
2092
|
-
if (isCompact(hit ? readerCap :
|
|
1035
|
+
if (isCompact(hit ? search.readerCap : search.listCap)) {
|
|
2093
1036
|
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", hit ? 'reader' : 'ricerca', " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7", ' ', "esc ", hit ? 'torna' : 'chiude'] })] }));
|
|
2094
1037
|
}
|
|
2095
1038
|
if (hit) {
|
|
2096
1039
|
// Niente `windowRange`: quella centra la finestra su una SELEZIONE, qui
|
|
2097
1040
|
// la posizione è lo scroll che l'utente muove a mano. Il clamp serve
|
|
2098
1041
|
// comunque — un resize può accorciare il testo sotto uno scroll già dato.
|
|
2099
|
-
const start = Math.min(readerTop, readerMaxTop);
|
|
2100
|
-
return (_jsx(ReaderScreen, { hit: hit, lines: readerLines.slice(start, start + readerCap), top: start, total: readerLines.length, capacity: readerCap, bound: bindings.get(hit.sessionId) ?? null }));
|
|
1042
|
+
const start = Math.min(search.readerTop, search.readerMaxTop);
|
|
1043
|
+
return (_jsx(ReaderScreen, { hit: hit, lines: search.readerLines.slice(start, start + search.readerCap), top: start, total: search.readerLines.length, capacity: search.readerCap, bound: bindings.get(hit.sessionId) ?? null }));
|
|
2101
1044
|
}
|
|
2102
|
-
const selIdx = rowIndexOfKey(
|
|
2103
|
-
const win = windowRange(
|
|
1045
|
+
const selIdx = rowIndexOfKey(search.rows, search.selKey);
|
|
1046
|
+
const win = windowRange(search.rows.length, selIdx, search.listCap);
|
|
2104
1047
|
// Anteprima dell'occorrenza selezionata: prende le righe che la lista non
|
|
2105
1048
|
// usa. Con molti risultati `spare` è 0 e il pannello non esiste — la lista
|
|
2106
1049
|
// se le riprende tutte, che è la priorità giusta quando c'è molto da
|
|
2107
1050
|
// scorrere. La finestra si CENTRA sul match (`windowRange`), così il
|
|
2108
1051
|
// contesto arriva da entrambi i lati.
|
|
2109
|
-
const spare = searchPreviewCapacity(
|
|
1052
|
+
const spare = searchPreviewCapacity(search.listCap, win.end - win.start);
|
|
2110
1053
|
let preview = null;
|
|
2111
|
-
if (spare >= 1 &&
|
|
2112
|
-
const h =
|
|
2113
|
-
const mline = Math.max(0,
|
|
2114
|
-
const pw = windowRange(
|
|
1054
|
+
if (spare >= 1 && search.selRow?.kind === 'hit') {
|
|
1055
|
+
const h = search.selRow.hit;
|
|
1056
|
+
const mline = Math.max(0, search.previewBody.findIndex((l) => l.end > h.matchStart));
|
|
1057
|
+
const pw = windowRange(search.previewBody.length, mline, spare);
|
|
2115
1058
|
preview = {
|
|
2116
1059
|
hit: h,
|
|
2117
|
-
lines:
|
|
1060
|
+
lines: search.previewBody.slice(pw.start, pw.end),
|
|
2118
1061
|
from: pw.start,
|
|
2119
|
-
total:
|
|
1062
|
+
total: search.previewBody.length,
|
|
2120
1063
|
ts: sessions.find((s) => s.sessionId === h.sessionId)?.ts ?? 0,
|
|
2121
1064
|
};
|
|
2122
1065
|
}
|
|
2123
|
-
return (_jsx(SearchScreen, { preview: preview, hash:
|
|
1066
|
+
return (_jsx(SearchScreen, { preview: preview, hash: search.hash, query: search.query, field: search.field, opts: search.opts, result: search.result, rows: search.rows.slice(win.start, win.end), selectedKey: search.selKey, selectedKind: selectedRow(search.rows, search.selKey)?.kind ?? null, above: win.start, below: search.rows.length - win.end, capacity: search.listCap, bindings: bindings, pinned: pinned, sessionNotes: sessionNotes, projectCore: projectCore, columns: columns, note: note }));
|
|
2124
1067
|
}
|
|
2125
1068
|
// ── Budget d'altezza ────────────────────────────────────────────────────
|
|
2126
1069
|
// Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette
|
|
@@ -2169,608 +1112,5 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
2169
1112
|
}
|
|
2170
1113
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${legend.shown}` : '', legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, counts: taskCounts, activeView: taskViewId, paneCount: paneTasks.length, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, counts: sessionCounts, activeView: sessionViewId, paneCount: sessionRows.length, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore, live: live })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '', live: live.get(selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
2171
1114
|
}
|
|
2172
|
-
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
2173
|
-
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
2174
|
-
// spingono giù i pane invece di coprirli, così la lista che stai filtrando
|
|
2175
|
-
// resta sempre visibile mentre la componi.
|
|
2176
|
-
function SortModal({ sort }) {
|
|
2177
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "S \u203A sort chain" }), sort.length === 0 ? (_jsx(Text, { dimColor: true, children: "nessuna chiave \u00B7 resta l'ordine per id \u2191" })) : (_jsx(Text, { children: sort
|
|
2178
|
-
.map((e, i) => `${i + 1}. ${SORT_UI[e.key]} ${e.dir === 'asc' ? '↑' : '↓'}`)
|
|
2179
|
-
.join(' ') }))] }));
|
|
2180
|
-
}
|
|
2181
|
-
function FilterModal({ view, cursor }) {
|
|
2182
|
-
const rows = [
|
|
2183
|
-
{ label: 'pri ', entries: PRI_ENTRIES, hidden: new Set(view.hiddenPri) },
|
|
2184
|
-
{ label: 'stato', entries: PROG_ENTRIES, hidden: new Set(view.hiddenProg) },
|
|
2185
|
-
];
|
|
2186
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "F \u203A filtri" }), rows.map((row, r) => (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: row.label }), row.entries.map((e, c) => {
|
|
2187
|
-
const on = !row.hidden.has(e.name);
|
|
2188
|
-
const here = cursor.row === r && cursor.col === c;
|
|
2189
|
-
return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", sanitize(e.glyph)] }, e.name));
|
|
2190
|
-
})] }, row.label)))] }));
|
|
2191
|
-
}
|
|
2192
|
-
/**
|
|
2193
|
-
* Campo di testo del modale edit: finestra ancorata al caret + cursore inverso
|
|
2194
|
-
* nella posizione REALE.
|
|
2195
|
-
*
|
|
2196
|
-
* Il cursore non è più uno spazio inverso appiccicato in coda ma la cella `at`
|
|
2197
|
-
* della finestra — cioè il carattere su cui il caret sta davvero. Fuori fuoco
|
|
2198
|
-
* (`focused` falso) il caret non si disegna e la finestra si ancora in fondo,
|
|
2199
|
-
* che è la vista utile per un campo che non si sta scrivendo.
|
|
2200
|
-
*/
|
|
2201
|
-
function EditTextField({ label, value, caret, focused, cols, }) {
|
|
2202
|
-
const win = caretWindow(value, focused ? caret : cpLen(value), cols);
|
|
2203
|
-
return (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: label }), _jsxs(Text, { children: [' ', sanitize(win.head)] }), focused ? _jsx(Text, { inverse: true, children: sanitize(win.at) }) : null, _jsx(Text, { children: sanitize(win.tail) })] }));
|
|
2204
|
-
}
|
|
2205
|
-
// T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
|
|
2206
|
-
// coprirli: la riga che stai modificando resta visibile sopra la lista).
|
|
2207
|
-
// La riga di anteprima mostra il testo ESATTO che finirà nel campo `Progress`
|
|
2208
|
-
// del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
|
|
2209
|
-
function EditModal({ id, draft, row, columns, }) {
|
|
2210
|
-
const mark = (r) => (row === r ? CARET : CARET_OFF);
|
|
2211
|
-
// Budget dei campi di testo, DERIVATO da `columns` (mai una costante): il box
|
|
2212
|
-
// del modale è ANNIDATO nella cornice del deck, quindi le cornici da scalare
|
|
2213
|
-
// sono due — root (bordo 2 + paddingX 2) e modale (bordo 2 + paddingX 2) — più
|
|
2214
|
-
// caret 2, etichetta 6, gap 2 e cursore 1. Totale 19.
|
|
2215
|
-
// Un titolo di tasks.md arriva a ~64 caratteri: senza taglio la riga va a capo
|
|
2216
|
-
// dentro il box, che si alza di una riga e sfonda il budget verticale (invariante ③).
|
|
2217
|
-
const fieldBudget = Math.max(8, columns - 19);
|
|
2218
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 titolo, priorit\u00E0 e stato"] }), _jsxs(Text, { children: [mark(0), _jsx(Text, { dimColor: true, children: "pri " }), EDIT_PRI.map((p) => (_jsxs(Text, { inverse: draft.pri === p, color: draft.pri === p ? 'green' : 'gray', children: [' ', sanitize(PRI_GLYPH[p]), " ", PRI_LABEL[p]] }, p)))] }), _jsxs(Text, { children: [mark(1), _jsx(Text, { dimColor: true, children: "stato " }), EDIT_PROG.map((p) => (_jsxs(Text, { inverse: draft.prog === p, color: draft.prog === p ? 'green' : 'gray', children: [' ', sanitize(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(EditTextField, { label: "prog ", value: draft.detail, caret: draft.caret, focused: row === 2, cols: fieldBudget }), !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { children: [mark(3), _jsx(EditTextField, { label: "titolo", value: draft.title, caret: draft.caret, focused: row === 3, cols: fieldBudget })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", sanitize(progressText(draft.prog, draft.detail))] })] }));
|
|
2219
|
-
}
|
|
2220
|
-
// T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
|
|
2221
|
-
// caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
|
|
2222
|
-
// allinearsi, e i glifi BMP larghi 2 sono proprio la classe che Ink e il
|
|
2223
|
-
// terminale misurano diversamente (vedi width.ts).
|
|
2224
|
-
const KIND_TAG = { ai: 'ai', tool: 'tl', human: 'hu' };
|
|
2225
|
-
const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
|
|
2226
|
-
/**
|
|
2227
|
-
* Larghezza dell'estratto, DERIVATA dalle colonne del terminale.
|
|
2228
|
-
*
|
|
2229
|
-
* Una costante qui è spazio buttato a ogni riga: su un terminale a 190 colonne
|
|
2230
|
-
* un estratto fisso a 50 lascia il match con ~20 caratteri di contesto per lato
|
|
2231
|
-
* quando potrebbe averne 80 — e il contesto attorno al match è l'unica ragione
|
|
2232
|
-
* per cui si legge la riga invece di aprire il reader.
|
|
2233
|
-
*
|
|
2234
|
-
* Scomposizione delle colonne consumate dalla cornice e dal prefisso di riga:
|
|
2235
|
-
* 4 box esterno (2 bordi + 2 padding)
|
|
2236
|
-
* 4 box lista (2 bordi + 2 padding)
|
|
2237
|
-
* 2 caret
|
|
2238
|
-
* 4 indice del record
|
|
2239
|
-
* 4 spazio + tag del kind (2 caratteri) + spazio
|
|
2240
|
-
* Prudente per costruzione: sottostimare tronca un carattere in più,
|
|
2241
|
-
* sovrastimare manderebbe la riga a capo e sfonderebbe il budget d'altezza.
|
|
2242
|
-
*/
|
|
2243
|
-
function searchExcerptWidth(columns) {
|
|
2244
|
-
return Math.max(30, (columns || 80) - 18);
|
|
2245
|
-
}
|
|
2246
|
-
/** Titolo della conversazione sulla riga-gruppo: prende ciò che avanza dopo le
|
|
2247
|
-
* colonne a larghezza fissa (caret, pin, hash, task, conteggio, data). */
|
|
2248
|
-
function searchTitleWidth(columns) {
|
|
2249
|
-
return Math.max(24, (columns || 80) - 56);
|
|
2250
|
-
}
|
|
2251
|
-
/**
|
|
2252
|
-
* Cosa scrivere sulla riga-gruppo per distinguere una conversazione dall'altra.
|
|
2253
|
-
*
|
|
2254
|
-
* Non basta `session.title`: quel titolo è la label della tab Ptyxis, cioè
|
|
2255
|
-
* `<emoji> <name>` più un eventuale suffisso. Su una lista tutta dello
|
|
2256
|
-
* stesso progetto (D3) è una COLONNA COSTANTE — tre righe su quattro
|
|
2257
|
-
* identiche, che è esattamente il difetto che la riga-gruppo doveva evitare.
|
|
2258
|
-
*
|
|
2259
|
-
* Si toglie quindi il core `<name>` con tutto ciò che lo precede (noto dal file
|
|
2260
|
-
* config), e se ciò
|
|
2261
|
-
* che resta è vuoto o duplica la task già mostrata nella sua colonna, si
|
|
2262
|
-
* ripiega sul primo prompt — l'unica cosa che davvero identifica quella
|
|
2263
|
-
* conversazione e non un'altra.
|
|
2264
|
-
*/
|
|
2265
|
-
function conversationLabel(s, core, bound) {
|
|
2266
|
-
let t = stripProjectCore(s.title, core);
|
|
2267
|
-
if (bound && t === bound)
|
|
2268
|
-
t = '';
|
|
2269
|
-
return t || s.firstPrompt || '(senza titolo)';
|
|
2270
|
-
}
|
|
2271
|
-
/**
|
|
2272
|
-
* Riga di toggle del modale ricerca.
|
|
2273
|
-
*
|
|
2274
|
-
* La mappa tasto→significato è SEMPRE a schermo: `^R` da solo è opaco quanto lo
|
|
2275
|
-
* era il range `1-9` delle launch prima di T43.
|
|
2276
|
-
*
|
|
2277
|
-
* Lo stato acceso/spento passa da `[x]`/`[ ]`, non dal solo colore — stessa
|
|
2278
|
-
* convenzione del modale filtri. Il colore è ridondanza, non l'informazione: su
|
|
2279
|
-
* un terminale monocromo, o in una cattura di testo, sei toggle tutti uguali
|
|
2280
|
-
* non direbbero più quali sono attivi.
|
|
2281
|
-
*/
|
|
2282
|
-
function ToggleHint({ opts }) {
|
|
2283
|
-
const flag = (on, key, label) => (_jsxs(Text, { color: on ? 'green' : 'gray', dimColor: !on, bold: on, children: [' ', key, "[", on ? 'x' : ' ', "] ", label] }, key));
|
|
2284
|
-
return (_jsxs(Text, { wrap: "truncate-end", children: [flag(opts.regex, '^R', 'regex'), flag(opts.caseSensitive, '^A', 'Aa'), flag(opts.wholeWord, '^W', 'word'), _jsx(Text, { dimColor: true, children: ' │' }), flag(opts.kinds.ai, '^B', 'IA'), flag(opts.kinds.tool, '^T', 'tools'), flag(opts.kinds.human, '^U', 'human')] }));
|
|
2285
|
-
}
|
|
2286
|
-
/** Intestazione della lista: dice sempre quanto NON si sta vedendo (regex rotta,
|
|
2287
|
-
* query troppo corta, occorrenze tagliate dal cap, righe fuori finestra). */
|
|
2288
|
-
function SearchListHeader({ result, query, above, below, }) {
|
|
2289
|
-
if (result.error) {
|
|
2290
|
-
return (_jsxs(Text, { color: "red", wrap: "truncate-end", children: [WARN, " regex non valida \u00B7 ", result.error] }));
|
|
2291
|
-
}
|
|
2292
|
-
if (result.idle) {
|
|
2293
|
-
return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: query.length === 0
|
|
2294
|
-
? 'digita la chiave da cercare'
|
|
2295
|
-
: `almeno ${MIN_QUERY} caratteri (${query.length})` }));
|
|
2296
|
-
}
|
|
2297
|
-
if (result.shown === 0) {
|
|
2298
|
-
return (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: "nessuna occorrenza" }));
|
|
2299
|
-
}
|
|
2300
|
-
return (_jsxs(Text, { bold: true, wrap: "truncate-end", children: [result.shown, " occorrenze in ", result.sessionCount, " conversazioni", result.hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", result.hidden, " oltre il cap"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }));
|
|
2301
|
-
}
|
|
2302
|
-
function SearchScreen({ preview, hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, sessionNotes, projectCore, columns, note, }) {
|
|
2303
|
-
const enter = selectedKind === 'session' ? 'resume' : selectedKind === 'hit' ? 'leggi' : '—';
|
|
2304
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["ricerca \u00B7 ", _jsx(Text, { color: "yellow", children: "tab" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " naviga \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " ", enter, " \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "hash " }), _jsx(Text, { color: field === 'hash' ? 'yellow' : undefined, children: hash }), field === 'hash' ? _jsx(Text, { inverse: true, children: " " }) : null, !hash ? _jsx(Text, { dimColor: true, children: " (vuoto = tutte le conversazioni)" }) : null] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "chiave " }), _jsx(Text, { color: field === 'query' ? 'yellow' : undefined, children: query }), field === 'query' ? _jsx(Text, { inverse: true, children: " " }) : null] }), _jsx(ToggleHint, { opts: opts })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsx(SearchListHeader, { result: result, query: query, above: above, below: below }), rows.slice(0, Math.max(0, capacity)).map((row) => {
|
|
2305
|
-
const sel = row.key === selectedKey;
|
|
2306
|
-
if (row.kind === 'session') {
|
|
2307
|
-
const s = row.session;
|
|
2308
|
-
const bound = bindings.get(s.sessionId);
|
|
2309
|
-
const rowNote = sessionNotes.get(s.sessionId);
|
|
2310
|
-
const noteShown = rowNote ? cut(rowNote, 24) : '';
|
|
2311
|
-
// `+3` = i due caporali e lo spazio che li separa dall'etichetta.
|
|
2312
|
-
// Il pavimento non è cosmetico: senza, un terminale stretto manda
|
|
2313
|
-
// l'argomento di `cut` sotto zero, cioè un budget negativo.
|
|
2314
|
-
// La nota si misura con `termWidth`, non con `.length`: contiene
|
|
2315
|
-
// testo umano, emoji compresi.
|
|
2316
|
-
const restWidth = Math.max(8, searchTitleWidth(columns) - (noteShown ? termWidth(noteShown) + 3 : 0));
|
|
2317
|
-
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), noteShown ? _jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", noteShown, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(noteShown), children: cut(conversationLabel(s, projectCore, bound), restWidth) }), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
|
|
2318
|
-
}
|
|
2319
|
-
const h = row.hit;
|
|
2320
|
-
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }), ' ', cut(h.excerpt, searchExcerptWidth(columns))] }, row.key));
|
|
2321
|
-
})] }), preview ? _jsx(SearchPreviewPane, { p: preview }) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
2322
|
-
}
|
|
2323
|
-
/**
|
|
2324
|
-
* Anteprima dell'occorrenza selezionata, sotto la lista.
|
|
2325
|
-
*
|
|
2326
|
-
* Riempie le righe che la lista non usa: con pochi risultati il terminale
|
|
2327
|
-
* resterebbe vuoto per tre quarti, e il contesto attorno al match è proprio
|
|
2328
|
-
* ciò che serve per decidere se è l'occorrenza giusta. Nel caso comune evita
|
|
2329
|
-
* del tutto di aprire il reader.
|
|
2330
|
-
*
|
|
2331
|
-
* Si aggiorna navigando con le frecce, e la finestra è centrata sul match:
|
|
2332
|
-
* stessa `windowRange` della lista, stessa evidenziazione del reader
|
|
2333
|
-
* (`ReaderLine`) — nessuna primitiva nuova.
|
|
2334
|
-
*/
|
|
2335
|
-
function SearchPreviewPane({ p }) {
|
|
2336
|
-
const last = Math.min(p.total, p.from + p.lines.length);
|
|
2337
|
-
const occ = [{ start: p.hit.matchStart, end: p.hit.matchEnd }];
|
|
2338
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["record ", p.hit.idx, " \u00B7 ", KIND_LABEL[p.hit.kind], p.ts ? ` · ${fmtDateTime(p.ts)}` : '', " \u00B7 righe ", p.from + 1, "-", last, " di ", p.total, " \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " apre il reader"] }), p.lines.map((l, i) => (_jsx(ReaderLine, { line: l, occ: occ, current: 0 }, p.from + i)))] }));
|
|
2339
|
-
}
|
|
2340
|
-
/**
|
|
2341
|
-
* Reader fullscreen (T52 · D8).
|
|
2342
|
-
*
|
|
2343
|
-
* Mostra il messaggio INTERO che contiene l'occorrenza, aperto già posizionato
|
|
2344
|
-
* sul match e con il match evidenziato. È un `mode` a sé, catturato prima del
|
|
2345
|
-
* ramo `search`: il modale ricerca resta montato sotto e su `esc` si ritrova
|
|
2346
|
-
* con query, toggle e selezione intatti.
|
|
2347
|
-
*/
|
|
2348
|
-
function ReaderScreen({ hit, lines, top, total, capacity, bound, }) {
|
|
2349
|
-
const last = Math.min(total, top + capacity);
|
|
2350
|
-
const occ = [{ start: hit.matchStart, end: hit.matchEnd }];
|
|
2351
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["reader \u00B7 ", _jsx(Text, { color: "cyan", children: hit.sessionId.slice(0, 8) }), " \u00B7 record ", hit.idx, " \u00B7", ' ', KIND_LABEL[hit.kind], bound ? ` · ${bound}` : '', " \u00B7 righe ", total === 0 ? 0 : top + 1, "-", last, " di ", total] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g" }), " inizio \u00B7 ", _jsx(Text, { color: "yellow", children: "G" }), " fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " torna alla lista"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: lines.map((l, i) => (_jsx(ReaderLine, { line: l, occ: occ, current: 0 }, top + i))) })] }));
|
|
2352
|
-
}
|
|
2353
|
-
/** Una riga con le porzioni di match evidenziate. Gli offset sono quelli del
|
|
2354
|
-
* testo sorgente, quindi un match a cavallo dell'a-capo si colora su entrambe
|
|
2355
|
-
* le righe senza casi speciali — entrambe intersecano il suo intervallo.
|
|
2356
|
-
*
|
|
2357
|
-
* Regge N occorrenze perché il detail (T91) ne mostra tutte quelle visibili; il
|
|
2358
|
-
* reader (T52) ne passa una sola, che è il caso degenere dello stesso taglio. */
|
|
2359
|
-
function ReaderLine({ line, occ, current, }) {
|
|
2360
|
-
const segs = sliceLine(line.text, line.start, occ, current);
|
|
2361
|
-
if (segs.length === 0)
|
|
2362
|
-
return _jsx(Text, { wrap: "truncate-end", children: line.text || ' ' });
|
|
2363
|
-
return (_jsx(Text, { wrap: "truncate-end", children: segs.map((s, i) => s.hit ? (
|
|
2364
|
-
// La corrente si distingue dalle altre per COLORE di sfondo, non per
|
|
2365
|
-
// presenza: tutte restano visibili, o navigare fra occorrenze non
|
|
2366
|
-
// mostrerebbe più dove sono le altre.
|
|
2367
|
-
_jsx(Text, { backgroundColor: s.current ? 'cyan' : 'yellow', color: "black", children: s.text }, i)) : (_jsx(Text, { children: s.text }, i))) }));
|
|
2368
|
-
}
|
|
2369
|
-
/** Resa di ogni costrutto markdown (T75 · D4): un solo livello di enfasi per
|
|
2370
|
-
* costrutto, senza un secondo alfabeto da imparare. Heading uguali a ogni
|
|
2371
|
-
* livello — la gerarchia la porta già il testo. `code` e `fence` condividono
|
|
2372
|
-
* il giallo perché sono lo stesso costrutto a due granularità: dargli due
|
|
2373
|
-
* colori direbbe che sono due cose. */
|
|
2374
|
-
const MD_STYLE = {
|
|
2375
|
-
heading: { bold: true, color: 'cyan' },
|
|
2376
|
-
bold: { bold: true },
|
|
2377
|
-
code: { color: 'yellow' },
|
|
2378
|
-
fence: { color: 'yellow' },
|
|
2379
|
-
};
|
|
2380
|
-
/**
|
|
2381
|
-
* Una riga del detail (T75): markdown reso, con sopra l'evidenziazione della
|
|
2382
|
-
* ricerca.
|
|
2383
|
-
*
|
|
2384
|
-
* Due segmentazioni sulla stessa riga, annidate e non fuse: prima si taglia sui
|
|
2385
|
-
* costrutti markdown, poi ogni pezzo si ritaglia sulle occorrenze. L'ordine non
|
|
2386
|
-
* è indifferente — così un match a cavallo di un `**grassetto**` resta
|
|
2387
|
-
* evidenziato per intero e insieme conserva il grassetto sulla metà che ce
|
|
2388
|
-
* l'ha, cosa che una segmentazione unica dovrebbe risolvere decidendo chi vince.
|
|
2389
|
-
*
|
|
2390
|
-
* Gli offset di `occ` e di `spans` indicizzano ENTRAMBI il testo reso: è ciò
|
|
2391
|
-
* che permette di comporli senza rimappature. Vedi `sheetDoc` per il perché la
|
|
2392
|
-
* ricerca del detail ha smesso di scandire il sorgente.
|
|
2393
|
-
*/
|
|
2394
|
-
function DetailLine({ line, spans, occ, current, }) {
|
|
2395
|
-
const styled = sliceSpans(line, spans);
|
|
2396
|
-
// Riga vuota → uno spazio: un `<Text>` senza contenuto Ink non lo disegna, e
|
|
2397
|
-
// il testo si compatterebbe perdendo la struttura del file.
|
|
2398
|
-
if (styled.length === 0)
|
|
2399
|
-
return _jsx(Text, { wrap: "truncate-end", children: " " });
|
|
2400
|
-
let off = line.start;
|
|
2401
|
-
return (_jsx(Text, { wrap: "truncate-end", children: styled.map((seg, i) => {
|
|
2402
|
-
const st = seg.kind ? MD_STYLE[seg.kind] : undefined;
|
|
2403
|
-
const at = off;
|
|
2404
|
-
off += seg.text.length;
|
|
2405
|
-
// Senza ricerca aperta il secondo taglio non ha niente da tagliare, e
|
|
2406
|
-
// saltarlo evita di allocare tre array per ogni riga a ogni freccia.
|
|
2407
|
-
if (occ.length === 0) {
|
|
2408
|
-
return (_jsx(Text, { bold: st?.bold, color: st?.color, children: seg.text }, i));
|
|
2409
|
-
}
|
|
2410
|
-
return (_jsx(Text, { bold: st?.bold, color: st?.color, children: sliceLine(seg.text, at, occ, current).map((p, j) => p.hit ? (_jsx(Text, { backgroundColor: p.current ? 'cyan' : 'yellow', color: "black", children: p.text }, j)) : (_jsx(Text, { children: p.text }, j))) }, i));
|
|
2411
|
-
}) }));
|
|
2412
|
-
}
|
|
2413
|
-
/** Campo della ricerca nel detail: finestra ancorata al caret, cursore inverso
|
|
2414
|
-
* sulla cella reale. Gemello di `EditTextField` senza la label, che qui sta
|
|
2415
|
-
* fuori perché il campo vive in FLUSSO su una riga condivisa col contatore —
|
|
2416
|
-
* non su una riga propria. */
|
|
2417
|
-
function DetailFindField({ value, caret, cols }) {
|
|
2418
|
-
const win = caretWindow(value, caret, cols);
|
|
2419
|
-
return (_jsxs(_Fragment, { children: [_jsx(Text, { children: sanitize(win.head) }), _jsx(Text, { inverse: true, children: sanitize(win.at) }), _jsx(Text, { children: sanitize(win.tail) })] }));
|
|
2420
|
-
}
|
|
2421
|
-
/**
|
|
2422
|
-
* Detail della task (T66): il task file scrollabile più la barra azioni.
|
|
2423
|
-
*
|
|
2424
|
-
* Unisce due gesti che erano due schermate — leggere la task e agire su di essa
|
|
2425
|
-
* — perché convergono sullo stesso oggetto: si legge la Description proprio per
|
|
2426
|
-
* decidere QUALE azione lanciare, e con due overlay separati quella decisione
|
|
2427
|
-
* costava uscire dal viewer e ricordarsi la combo.
|
|
2428
|
-
*
|
|
2429
|
-
* Le azioni sono BOTTONI AFFIANCATI e non voci di un menu verticale: un
|
|
2430
|
-
* rettangolo ha già coordinate e area cliccabile, quindi il layout sopravvive
|
|
2431
|
-
* all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione. La
|
|
2432
|
-
* navigazione da tastiera ci si sovrappone senza conflitti.
|
|
2433
|
-
*/
|
|
2434
|
-
function DetailScreen({ id, title, missing, lines, spans, top, total, capacity, action, columns, find, occ, occCur, }) {
|
|
2435
|
-
const last = Math.min(total, top + capacity);
|
|
2436
|
-
// Il taglio lo fa il chiamante (invariante ③ di width.ts): la riga bottoni è
|
|
2437
|
-
// ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso — ma
|
|
2438
|
-
// la correttezza non deve dipendere dall'alfabeto che capita nella riga.
|
|
2439
|
-
const width = Math.max(20, (columns || 80) - 4);
|
|
2440
|
-
const segs = DETAIL_ACTIONS.map((a) => `[ ${a.label} ]`);
|
|
2441
|
-
const parts = [];
|
|
2442
|
-
segs.forEach((s, i) => {
|
|
2443
|
-
if (i > 0)
|
|
2444
|
-
parts.push(' ');
|
|
2445
|
-
parts.push(s);
|
|
2446
|
-
});
|
|
2447
|
-
const dropped = (v) => segs.filter((s, i) => v[i * 2] !== s).length;
|
|
2448
|
-
// Due passate: la seconda serve SOLO quando qualcosa cade, e riserva le
|
|
2449
|
-
// colonne del contatore. Riservarle sempre costerebbe 6 colonne su ogni
|
|
2450
|
-
// terminale largo per un avviso che lì non comparirà mai.
|
|
2451
|
-
let shown = cutParts(parts, width);
|
|
2452
|
-
if (dropped(shown) > 0)
|
|
2453
|
-
shown = cutParts(parts, Math.max(0, width - 6));
|
|
2454
|
-
const cutCount = dropped(shown);
|
|
2455
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: id }), " \u00B7 ", cut(title, Math.max(10, width - 34)), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), find?.open ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " occorrenza \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " caret \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " tieni \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^F" }), " cerca \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " esegui \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] })), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " task file non trovato \u00B7 le azioni restano attive (deck-run risolve la task per id)"] })) : (lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, occ: occ, current: occCur }, top + i)))) }), find?.open ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "cerca " }), _jsx(DetailFindField, { value: find.q, caret: find.caret, cols: Math.max(10, width - 28) }), find.q.length === 0 ? (_jsx(Text, { dimColor: true, children: " \u00B7 digita per cercare" })) : occ.length === 0 ? (_jsx(Text, { color: "yellow", children: " \u00B7 nessuna occorrenza" })) : (_jsxs(Text, { color: "cyan", children: [' ', "\u00B7 ", occCur + 1, "/", occ.length] }))] }) })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === action, color: i / 2 === action ? 'green' : 'gray', children: part }, i))), cutCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cutCount] }) : null] }) })] }));
|
|
2456
|
-
}
|
|
2457
|
-
/**
|
|
2458
|
-
* Larghezza del testo dentro la lista della schermata di assegnazione: box
|
|
2459
|
-
* esterno (2 bordi + 2 padding) + box lista (2 bordi + 2 padding).
|
|
2460
|
-
*
|
|
2461
|
-
* Invariante ③ (`width.ts`): il taglio lo fa il chiamante. Delegarlo a
|
|
2462
|
-
* `wrap="truncate-end"` passerebbe da `cli-truncate`, che restituisce una riga
|
|
2463
|
-
* più larga di quella chiesta di una colonna per emoji — e quelle colonne
|
|
2464
|
-
* finiscono sopra il bordo, che sparisce dalla riga.
|
|
2465
|
-
*/
|
|
2466
|
-
function assignTextWidth(columns) {
|
|
2467
|
-
return Math.max(20, (columns || 80) - 8);
|
|
2468
|
-
}
|
|
2469
|
-
/**
|
|
2470
|
-
* Schermata di assegnazione di una conversazione a una task (T57).
|
|
2471
|
-
*
|
|
2472
|
-
* Fullscreen sostitutiva (D3) come la ricerca: la lista task non entra in un
|
|
2473
|
-
* box sopra i due pane. Da lì discende che l'oggetto dell'azione — la sessione
|
|
2474
|
-
* selezionata, che non è più a schermo — va ripetuto nel titolo.
|
|
2475
|
-
*
|
|
2476
|
-
* L'header della lista conta le task escluse dai filtri della vista (D4): la
|
|
2477
|
-
* scelta di mostrare `viewTasks` e non tutte le task ha come prezzo noto che un
|
|
2478
|
-
* filtro può nascondere proprio il bersaglio, e quel prezzo non deve essere
|
|
2479
|
-
* silenzioso — stessa convenzione del `+N più vecchie` del pane sessioni.
|
|
2480
|
-
*/
|
|
2481
|
-
function AssignScreen({ sessionId, label, current, filter, rows, selected, matched, hidden, above, below, childCount, columns, note, }) {
|
|
2482
|
-
const width = assignTextWidth(columns);
|
|
2483
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["assegna ", _jsx(Text, { color: "cyan", children: sessionId.slice(0, 8) }), label ? ` «${cut(sanitize(label), Math.max(10, Math.floor(width / 4)))}»` : '', " \u00B7 ora", ' ', current ? _jsx(Text, { color: "green", children: current }) : 'spot', " \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), ' ', "assegna \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " pulisci \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }), _jsx(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "filtro " }), _jsx(Text, { color: "yellow", children: cut(filter, Math.max(8, width - 24)) }), _jsx(Text, { inverse: true, children: " " }), !filter ? _jsx(Text, { dimColor: true, children: " (id o titolo)" }) : null] }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [matched, " task", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", hidden, " fuori dai filtri"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), rows.map((task) => {
|
|
2484
|
-
// T57/D2 — `detach` è una VOCE della lista, non un tasto a parte: un
|
|
2485
|
-
// solo gesto (`A`), un solo modale. Speculare alla riga meta `spot`
|
|
2486
|
-
// del pane task, e nominata con l'AZIONE («detach») invece che con lo
|
|
2487
|
-
// stato d'arrivo — è ciò che si sta per fare.
|
|
2488
|
-
if (!task) {
|
|
2489
|
-
const sel = selected === null;
|
|
2490
|
-
// Le colonne fisse (caret + `○ detach` + i due spazi) sono 12: solo
|
|
2491
|
-
// la glossa si taglia, così su un terminale stretto resta comunque
|
|
2492
|
-
// il nome dell'azione invece di un moncone di frase.
|
|
2493
|
-
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, "\u25CB detach ", cut('la sessione torna spot', width - 12)] }, "detach"));
|
|
2494
|
-
}
|
|
2495
|
-
const sel = selected === task.id;
|
|
2496
|
-
const n = childCount.get(task.id) ?? 0;
|
|
2497
|
-
const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
|
|
2498
|
-
const tail = n > 0 ? ` (${n})` : '';
|
|
2499
|
-
const desc = cut(task.desc, Math.max(4, width - termWidth(head) - termWidth(tail)));
|
|
2500
|
-
return (_jsxs(Text, { inverse: sel, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
|
|
2501
|
-
})] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
2502
|
-
}
|
|
2503
|
-
/**
|
|
2504
|
-
* Header del pane task, tagliato QUI e non da Ink — stesso motivo del gemello
|
|
2505
|
-
* `SessionsHeader`, con una differenza di rischio: qui i segmenti sono tutti
|
|
2506
|
-
* ASCII più le frecce `↑↓` (larghe 1), quindi `cli-truncate` oggi darebbe la
|
|
2507
|
-
* riga giusta per caso. Il taglio resta del deck perché la correttezza non deve
|
|
2508
|
-
* dipendere dall'alfabeto che capita nella riga: il primo glifo largo 2 che
|
|
2509
|
-
* entrasse in un segmento nuovo riaprirebbe il difetto in silenzio, e a
|
|
2510
|
-
* scoprirlo sarebbe il bordo del pane a schermo.
|
|
2511
|
-
*
|
|
2512
|
-
* `truncate-end` taglia dalla coda, e `cutParts` conserva l'ordine: l'ultimo
|
|
2513
|
-
* segmento resta il primo a cedere il posto (`↑↓` in coda alle voci navigabili).
|
|
2514
|
-
*
|
|
2515
|
-
* T100 — la riga non è più informativa: le voci del catalogo sono SELEZIONABILI
|
|
2516
|
-
* con ←/→, e l'attiva si distingue in video inverso (D5 — costa 0 colonne e non
|
|
2517
|
-
* entra in gara con la semantica di colore già occupata). Le voci ci sono tutte
|
|
2518
|
-
* anche a 0 (D1): un catalogo che si accorcia sposta le voci sotto le dita.
|
|
2519
|
-
* L'ordine è vincolato — le navigabili PRIMA di `↑N`/`↓N`, che cadono per primi
|
|
2520
|
-
* su un terminale stretto — e la voce attiva ha la precedenza sul budget (D6).
|
|
2521
|
-
*/
|
|
2522
|
-
function TasksHeader({ counts, active, above, below, focused, columns, }) {
|
|
2523
|
-
const views = TASK_VIEWS.map((v, i) => {
|
|
2524
|
-
const n = v.count(counts);
|
|
2525
|
-
return {
|
|
2526
|
-
// Il separatore sta nel segmento, non fra i segmenti: `cutParts` misura la
|
|
2527
|
-
// riga pezzo per pezzo e uno spazio fuori dai pezzi non verrebbe contato.
|
|
2528
|
-
text: `${i > 0 ? ' · ' : ''}${v.label(counts)}`,
|
|
2529
|
-
color: v.color,
|
|
2530
|
-
dim: v.dim || n === 0,
|
|
2531
|
-
active: v.id === active,
|
|
2532
|
-
};
|
|
2533
|
-
});
|
|
2534
|
-
const segments = [
|
|
2535
|
-
...views,
|
|
2536
|
-
{ text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false, color: undefined },
|
|
2537
|
-
{ text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false, color: undefined },
|
|
2538
|
-
];
|
|
2539
|
-
const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
|
|
2540
|
-
return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, inverse: seg.active, children: shown[i] }, i)) : null) }));
|
|
2541
|
-
}
|
|
2542
|
-
function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, }) {
|
|
2543
|
-
const allSelected = selected === ROW_ALL;
|
|
2544
|
-
const spotSelected = selected === ROW_SPOT;
|
|
2545
|
-
return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(TasksHeader, { counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(sanitize(`sort: ${describeSort(view.sort)}` +
|
|
2546
|
-
(view.hiddenPri.length + view.hiddenProg.length > 0
|
|
2547
|
-
? ' · filtri: ' +
|
|
2548
|
-
[
|
|
2549
|
-
...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
|
|
2550
|
-
...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
|
|
2551
|
-
]
|
|
2552
|
-
.map((e) => `−${e.glyph}`)
|
|
2553
|
-
.join(' ')
|
|
2554
|
-
: '')), paneTextWidth(columns)) }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", allCount > 0 ? ` (${allCount})` : ''] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : paneCount === 0 ? (
|
|
2555
|
-
// T100/D1 — una voce a contatore 0 resta navigabile, e selezionarla dà
|
|
2556
|
-
// una lista vuota che DICE perché è vuota. Senza la nota il pane si
|
|
2557
|
-
// legge come rotto: le righe meta restano, le task no, e niente spiega
|
|
2558
|
-
// che è la vista scelta a non contenere nulla.
|
|
2559
|
-
_jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(taskView(activeView).empty, paneTextWidth(columns)) })) : (tasks.map((task, i) => {
|
|
2560
|
-
// windowStart riporta l'indice di finestra a quello della lista
|
|
2561
|
-
// completa, su cui è keyata la selezione. +META_ROWS: le prime due
|
|
2562
|
-
// righe sono le meta.
|
|
2563
|
-
const sel = windowStart + i + META_ROWS === selected;
|
|
2564
|
-
const n = childCount.get(task.id) ?? 0;
|
|
2565
|
-
// Invariante ③: la descrizione è l'unico pezzo a lunghezza libera, e
|
|
2566
|
-
// si taglia QUI sul budget che resta dopo le colonne fisse. Lasciarlo
|
|
2567
|
-
// fare a `truncate-end` significa passare da `cli-truncate`, che
|
|
2568
|
-
// restituisce una riga più larga del pane (una colonna per emoji) e
|
|
2569
|
-
// quindi scrive sopra il bordo. Le parti fisse si misurano con
|
|
2570
|
-
// `termWidth`: `task.id` è `T9` o `T52`, i due glifi valgono 2 ciascuno.
|
|
2571
|
-
const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
|
|
2572
|
-
const tail = n > 0 ? ` (${n})` : '';
|
|
2573
|
-
const desc = cut(task.desc, Math.max(4, paneTextWidth(columns) - termWidth(head) - termWidth(tail)));
|
|
2574
|
-
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
|
|
2575
|
-
}))] }));
|
|
2576
|
-
}
|
|
2577
|
-
/**
|
|
2578
|
-
* Header del pane sessioni, tagliato QUI e non da Ink.
|
|
2579
|
-
*
|
|
2580
|
-
* `📌{pinnedCount}` è un glifo largo 2 in mezzo alla riga: con
|
|
2581
|
-
* `wrap="truncate-end"` il taglio passava da `cli-truncate`, che indicizza per
|
|
2582
|
-
* code point con un budget in colonne e restituiva una riga larga 45 su un
|
|
2583
|
-
* budget di 44 — la colonna in più finiva sopra il bordo destro del pane, che
|
|
2584
|
-
* spariva dalla riga (invariante ③ di `width.ts`).
|
|
2585
|
-
*
|
|
2586
|
-
* I segmenti restano segmenti fino al render: il taglio è della RIGA (budget
|
|
2587
|
-
* condiviso, `cutParts`), la resa è del pezzo. Il giallo su `📌N` distingue le
|
|
2588
|
-
* pinnate dal resto dell'header e non è decorazione.
|
|
2589
|
-
*/
|
|
2590
|
-
function SessionsHeader({ parentLabel, counts, active, above, below, focused, columns, }) {
|
|
2591
|
-
const views = SESSION_VIEWS.map((v) => {
|
|
2592
|
-
const n = v.count(counts);
|
|
2593
|
-
return {
|
|
2594
|
-
text: ` · ${v.label(counts, parentLabel)}`,
|
|
2595
|
-
color: v.color,
|
|
2596
|
-
dim: v.dim || n === 0,
|
|
2597
|
-
active: v.id === active,
|
|
2598
|
-
};
|
|
2599
|
-
});
|
|
2600
|
-
const segments = [
|
|
2601
|
-
// `Sessions` non è una voce del catalogo: nomina il pane, non un
|
|
2602
|
-
// sottoinsieme, quindi non è raggiungibile con le frecce.
|
|
2603
|
-
{ text: 'Sessions', color: undefined, dim: false, active: false },
|
|
2604
|
-
...views,
|
|
2605
|
-
{ text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false, color: undefined },
|
|
2606
|
-
{ text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false, color: undefined },
|
|
2607
|
-
];
|
|
2608
|
-
const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
|
|
2609
|
-
return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, inverse: seg.active, children: shown[i] }, i)) : null) }));
|
|
2610
|
-
}
|
|
2611
|
-
function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, counts, activeView, paneCount, selectedId, focused, above, below, columns, forkOf, sessionNotes, projectCore, live, }) {
|
|
2612
|
-
return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(SessionsHeader, { parentLabel: parentLabel, counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), paneCount === 0 ? (
|
|
2613
|
-
// T100 — la nota della vista di default resta quella storica, che nomina
|
|
2614
|
-
// il PARENT (task, spot o tutte); le altre tre viste portano la propria,
|
|
2615
|
-
// che nomina il sottoinsieme. Sono due vuoti diversi: «questo parent non
|
|
2616
|
-
// ha conversazioni» e «questo sottoinsieme del parent è vuoto».
|
|
2617
|
-
_jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(sessionView(activeView).empty ??
|
|
2618
|
-
(isAll
|
|
2619
|
-
? 'nessuna conversazione nel progetto'
|
|
2620
|
-
: isSpot
|
|
2621
|
-
? 'nessuna sessione libera'
|
|
2622
|
-
: 'nessuna sessione legata a questa task'), paneTextWidth(columns)) })) : (rows.map((row, i) => {
|
|
2623
|
-
// T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
|
|
2624
|
-
// box pesante (coerente con lo styling delle Done dimmate).
|
|
2625
|
-
if (row.kind === 'separator') {
|
|
2626
|
-
return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: SESSION_SEP }, `sep${i}`));
|
|
2627
|
-
}
|
|
2628
|
-
const sel = row.sessionId === selectedId;
|
|
2629
|
-
// T50 — pin stale: transcript sparito, nessuna Session da mostrare.
|
|
2630
|
-
// Riga navigabile e spinnabile (`p`), marcata, mai un crash.
|
|
2631
|
-
if (row.kind === 'pinned' && row.stale) {
|
|
2632
|
-
// T60 — anche qui la nota si taglia sul budget DERIVATO, non su un
|
|
2633
|
-
// 30 inchiodato: su un pane stretto quel valore fisso mandava la
|
|
2634
|
-
// riga oltre il bordo, e a ripararla arrivava `cli-truncate` (che
|
|
2635
|
-
// sfora di una colonna per emoji e mangia il bordo stesso).
|
|
2636
|
-
const staleNote = sessionNotes.get(row.sessionId);
|
|
2637
|
-
// La riga stale è libera (niente colonne: non ha né titolo né
|
|
2638
|
-
// data), ma il binding va detto lo stesso — è una pinnata, quindi
|
|
2639
|
-
// l'header del pane non ne dice l'appartenenza.
|
|
2640
|
-
const staleTask = bindings.get(row.sessionId) ?? null;
|
|
2641
|
-
const staleW = Math.max(0, paneTextWidth(columns) -
|
|
2642
|
-
(2 /* caret */ +
|
|
2643
|
-
termWidth(`${WARN} pin stale `) +
|
|
2644
|
-
SID_CHARS +
|
|
2645
|
-
(staleTask ? termWidth(staleTask) + 1 : 0) +
|
|
2646
|
-
3 /* spazio + caporali */));
|
|
2647
|
-
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, SID_CHARS) }), staleTask ? _jsxs(Text, { color: "green", children: [" ", staleTask] }) : null, staleNote ? _jsxs(Text, { color: "yellow", children: [" \u00AB", cut(staleNote, staleW), "\u00BB"] }) : null] }, row.sessionId));
|
|
2648
|
-
}
|
|
2649
|
-
const s = row.session; // non-stale → session presente
|
|
2650
|
-
const isPinnedRow = row.kind === 'pinned';
|
|
2651
|
-
// T28 — un ramo eredita il titolo dell'origine: senza marcatore le due
|
|
2652
|
-
// righe sarebbero identiche a occhio.
|
|
2653
|
-
const forked = forkOf.has(s.sessionId);
|
|
2654
|
-
// T59 D2 — nella vista "tutte" il marker è PER-SESSIONE (binding letto
|
|
2655
|
-
// dal sidecar) e non deciso dal parent: la lista mescola scoped e spot,
|
|
2656
|
-
// quindi un marker uniforme mentirebbe su metà delle righe. E il solo
|
|
2657
|
-
// glifo direbbe *che* la conversazione è legata senza dire *a cosa* —
|
|
2658
|
-
// informazione monca proprio qui, l'unica vista dove l'appartenenza
|
|
2659
|
-
// non è scritta da nessun'altra parte dello schermo: da qui la colonna
|
|
2660
|
-
// task accanto, che esiste solo in questa vista.
|
|
2661
|
-
const bound = bindings.get(s.sessionId) ?? null;
|
|
2662
|
-
const linked = isAll ? Boolean(bound) : !isSpot;
|
|
2663
|
-
// T62 — liveness e binding sono ORTOGONALI: la cella marker dice a chi
|
|
2664
|
-
// appartiene la conversazione (pin/task/spot), questa dice se è aperta
|
|
2665
|
-
// adesso. Farle condividere una cella perderebbe una delle due.
|
|
2666
|
-
const liveEntry = live.get(s.sessionId);
|
|
2667
|
-
// Stesso motivo per cui la colonna esiste: una pinnata resta in lista
|
|
2668
|
-
// qualunque sia il parent selezionato, quindi l'header non ne dice
|
|
2669
|
-
// l'appartenenza e la cella va riempita anche fuori dalla vista
|
|
2670
|
-
// "tutte". Sulle contestuali, dove l'header parla già, resta vuota —
|
|
2671
|
-
// ma la cella è comunque larga `taskW`, o le colonne a destra
|
|
2672
|
-
// slitterebbero riga per riga.
|
|
2673
|
-
const taskCell = isAll || isPinnedRow ? (bound ?? TASK_EMPTY) : '';
|
|
2674
|
-
// T60 — colonne VERE: ogni cella fissa è larga esattamente quanto
|
|
2675
|
-
// dichiara, riempita di spazi con `pad` (che misura in colonne, non in
|
|
2676
|
-
// caratteri). Il marker va portato a 2 anche quando è `○`, largo 1:
|
|
2677
|
-
// era lui a far slittare a sinistra di una colonna tutta la riga di
|
|
2678
|
-
// ogni sessione spot.
|
|
2679
|
-
const age = relTime(s.ts);
|
|
2680
|
-
// Il taglio del titolo è ciò che RESTA, calcolato per sottrazione: le
|
|
2681
|
-
// colonne fisse sono note, quindi l'unica cella elastica prende il
|
|
2682
|
-
// resto. Pavimento `0` e non un minimo di cortesia — è un tetto, non
|
|
2683
|
-
// una preferenza: alzarlo sopra lo spazio reale fa uscire la riga dal
|
|
2684
|
-
// pane e le mangia il bordo (invariante ③).
|
|
2685
|
-
const titleW = Math.max(0, paneTextWidth(columns) -
|
|
2686
|
-
(2 /* caret */ +
|
|
2687
|
-
2 /* marker */ +
|
|
2688
|
-
1 /* gutter */ +
|
|
2689
|
-
1 /* T62 · colonna liveness */ +
|
|
2690
|
-
SID_CHARS +
|
|
2691
|
-
1 /* gutter */ +
|
|
2692
|
-
(taskW > 0 ? taskW + 1 : 0) +
|
|
2693
|
-
1 /* gutter prima della data */ +
|
|
2694
|
-
ageW));
|
|
2695
|
-
// T28 — `⑂` sta DENTRO la cella titolo, non in una colonna sua: una
|
|
2696
|
-
// colonna dedicata costerebbe 2 spazi vuoti su ogni riga non-fork, e
|
|
2697
|
-
// metterlo fuori cella sposterebbe il bordo del titolo solo sui rami —
|
|
2698
|
-
// cioè rimetterebbe lo slittamento che le colonne tolgono.
|
|
2699
|
-
const forkMark = forked ? '⑂ ' : '';
|
|
2700
|
-
const inner = Math.max(0, titleW - termWidth(forkMark));
|
|
2701
|
-
// T60 — il testo arriva già ripulito di ciò che le colonne accanto
|
|
2702
|
-
// dicono già (progetto e task id): senza, la cella conterrebbe
|
|
2703
|
-
// `🧵 loom-works · T59` accanto a una colonna che dice `T59`.
|
|
2704
|
-
const label = rowLabel(sessionTitle(s, projectCore, bound), sessionNotes.get(s.sessionId), inner);
|
|
2705
|
-
const used = (label.note ? termWidth(label.note) + 2 : 0) +
|
|
2706
|
-
(label.note && label.rest ? 1 : 0) +
|
|
2707
|
-
termWidth(label.rest);
|
|
2708
|
-
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : undefined, children: liveEntry ? (liveEntry.status === 'busy' ? LIVE_BUSY : LIVE_IDLE) : LIVE_NONE }), _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : 'cyan', bold: Boolean(liveEntry), children: s.sessionId.slice(0, SID_CHARS) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound && taskCell ? 'green' : undefined, dimColor: !bound, children: pad(taskCell, taskW) }), ' '] })) : null, forkMark ? _jsx(Text, { color: "magenta", children: forkMark }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' '.repeat(Math.max(0, inner - used)), ' ', _jsx(Text, { dimColor: true, children: pad(age, ageW, 'right') })] }, s.sessionId));
|
|
2709
|
-
}))] }));
|
|
2710
|
-
}
|
|
2711
|
-
function PreviewPane(p) {
|
|
2712
|
-
return (_jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: p.kind === 'task' ? (_jsx(TaskPreview, { detail: p.detail, maxLines: p.maxLines, columns: p.columns })) : (_jsx(SessionPreview, { s: p.s, firstLines: p.firstLines, lastLines: p.lastLines, columns: p.columns, origin: p.origin, note: p.note, live: p.live })) }));
|
|
2713
|
-
}
|
|
2714
|
-
// T49 — corpo della preview sessione. Tutti i campi vengono dal parse già
|
|
2715
|
-
// cached dell'adapter (mtime-keyed): non costa I/O al movimento di selezione.
|
|
2716
|
-
// Mostra "da dove parte, dove è arrivata": il primo prompt utente (`» `) e
|
|
2717
|
-
// l'ultima risposta del modello (`« `). L'anteprima del primo prompt compare
|
|
2718
|
-
// SOLO con un titolo custom — senza, il titolo È già il primo prompt e la riga
|
|
2719
|
-
// lo duplicherebbe (D4 preflight). Le righe rese non superano mai il riservato
|
|
2720
|
-
// dal budget (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
|
|
2721
|
-
function SessionPreview({ s, firstLines, lastLines, columns, origin, note, live, }) {
|
|
2722
|
-
const width = previewTextWidth(columns);
|
|
2723
|
-
const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
|
|
2724
|
-
const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
|
|
2725
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), " \u00B7 ", s.gitBranch || '-', origin ? ` · ⑂ da ${origin.slice(0, 8)}` : '', live ? (_jsx(Text, { color: live.status === 'busy' ? 'yellow' : 'green', children: ` · ${live.status === 'busy' ? LIVE_BUSY : LIVE_IDLE} viva pid ${live.pid} (${live.status})` })) : null] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
|
|
2726
|
-
}
|
|
2727
|
-
/**
|
|
2728
|
-
* Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
|
|
2729
|
-
* Estratto dal componente perché il budget deve saperlo PRIMA di renderizzare:
|
|
2730
|
-
* sono righe fisse che tolgono spazio alla descrizione.
|
|
2731
|
-
*/
|
|
2732
|
-
function detailMetaOf(detail) {
|
|
2733
|
-
const meta = META_KEYS.map((k) => detail.fields[k])
|
|
2734
|
-
.filter(Boolean)
|
|
2735
|
-
.join(' · ');
|
|
2736
|
-
const commit = detail.fields['Last tracked commit'] ?? '';
|
|
2737
|
-
return { meta, commit, metaLines: 1 + (meta ? 1 : 0) + (commit ? 1 : 0) };
|
|
2738
|
-
}
|
|
2739
|
-
/**
|
|
2740
|
-
* T70 — larghezza utile del testo dentro il blocco preview, che è a PIENA
|
|
2741
|
-
* larghezza: box esterno (2 bordi + 2 padding) → box preview (2 bordi + 2
|
|
2742
|
-
* padding). Niente più `/2`: il blocco non vive più dentro un pane al 50%,
|
|
2743
|
-
* quindi una riga di descrizione dispone del doppio delle colonne.
|
|
2744
|
-
*
|
|
2745
|
-
* Volutamente prudente: sottostimare tronca qualche carattere in più,
|
|
2746
|
-
* sovrastimare farebbe andare a capo una riga e sforare il tetto d'altezza.
|
|
2747
|
-
*/
|
|
2748
|
-
function previewTextWidth(columns) {
|
|
2749
|
-
return Math.max(10, (columns || 80) - 8);
|
|
2750
|
-
}
|
|
2751
|
-
/**
|
|
2752
|
-
* Larghezza del TESTO dentro un pane al 50%: box esterno (2 bordi + 2 padding)
|
|
2753
|
-
* → metà → bordo + padding del pane.
|
|
2754
|
-
*
|
|
2755
|
-
* Invariante ③ (`width.ts`): chi renderizza una riga a lunghezza libera la
|
|
2756
|
-
* taglia PRIMA con questa larghezza. Delegarlo a `wrap="truncate-end"`
|
|
2757
|
-
* significa passare da `cli-truncate`, che indicizza per code point e restituisce
|
|
2758
|
-
* una riga più larga di quella chiesta — una colonna per ogni emoji astrale a
|
|
2759
|
-
* sinistra del taglio. Quelle colonne finiscono sopra il bordo del pane, che
|
|
2760
|
-
* sparisce dalla riga: è la sminchiatura visibile a schermo.
|
|
2761
|
-
*/
|
|
2762
|
-
function paneTextWidth(columns) {
|
|
2763
|
-
return Math.max(20, Math.floor(((columns || 80) - 4) / 2) - 4);
|
|
2764
|
-
}
|
|
2765
|
-
/** Corpo della preview task: titolo, meta, descrizione wrappata, commit. */
|
|
2766
|
-
function TaskPreview({ detail, maxLines, columns, }) {
|
|
2767
|
-
const { meta, commit } = detailMetaOf(detail);
|
|
2768
|
-
// Wrap calcolato qui, non delegato a `<Text wrap="wrap">`: il budget ha
|
|
2769
|
-
// riservato ESATTAMENTE `maxLines` righe, e un wrap deciso da Ink a runtime
|
|
2770
|
-
// ne produrrebbe un numero che il budget non conosce — cioè il frame torna a
|
|
2771
|
-
// sforare e il bug si riapre da questa singola casella di testo.
|
|
2772
|
-
const lines = wrapLines(detail.description ?? '', previewTextWidth(columns), maxLines);
|
|
2773
|
-
return (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: detail.title || detail.id }), meta ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: meta }) : null, lines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", children: line }, i))), commit ? _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", commit] }) : null] }));
|
|
2774
|
-
}
|
|
2775
1115
|
const cwd = process.cwd();
|
|
2776
1116
|
render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }));
|