@lamemind/loom-deck 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -9
- package/dist/cli.js +138 -57
- package/dist/width.js +96 -0
- package/package.json +1 -1
- package/scripts/deck-run +95 -18
package/README.md
CHANGED
|
@@ -4,15 +4,19 @@ Deck TUI (Ink) **per-progetto** della famiglia [loom](https://github.com/lamemin
|
|
|
4
4
|
|
|
5
5
|
Legge il `tasks.md` del progetto e, con un tasto (poi un click), **spawna** una tab
|
|
6
6
|
[Ptyxis](https://gitlab.gnome.org/chergert/ptyxis) che avvia una sessione Claude Code
|
|
7
|
-
già bound alla task via `LOOM_TASK
|
|
7
|
+
già bound alla task via `LOOM_TASK`.
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
↑↓ scegli la task → ⏎ → tab CC di fianco → LOOM_TASK bound
|
|
10
|
+
↑↓ scegli la task → ⏎ → tab CC di fianco → LOOM_TASK bound
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
Il tasto premuto sceglie **con quale prompt** si entra: `⏎` a mani nude, `^K`
|
|
14
|
+
recap, `^P` preflight, `^R` esecuzione (vedi [I quattro modi di entrare in una
|
|
15
|
+
task](#i-quattro-modi-di-entrare-in-una-task)).
|
|
16
|
+
|
|
13
17
|
Entrambi i prefissi del contratto loom entrano in lista: **`T`** (code task) e
|
|
14
|
-
**`D`** (doc task).
|
|
15
|
-
|
|
18
|
+
**`D`** (doc task). Il trattamento è identico ovunque tranne che su `^R`, dove il
|
|
19
|
+
prompt dispatcha sulla skill di esecuzione della rispettiva famiglia.
|
|
16
20
|
|
|
17
21
|
## Ruolo nella famiglia loom
|
|
18
22
|
|
|
@@ -52,10 +56,10 @@ senza collisioni:
|
|
|
52
56
|
|---|---|---|
|
|
53
57
|
| `↑` `↓` | naviga nella lista | |
|
|
54
58
|
| `←` `→` `tab` | cambia pane | |
|
|
55
|
-
| `⏎` | azione primaria del pane | Tasks → spawna la task selezionata · Sessions → riprende (`claude --resume`) la sessione selezionata |
|
|
59
|
+
| `⏎` | azione primaria del pane | Tasks → spawna la task selezionata (senza prompt) · Sessions → riprende (`claude --resume`) la sessione selezionata |
|
|
56
60
|
| **MAIUSCOLA** | **apre un modale** | cattura tutti i tasti; `esc` annulla, non esce |
|
|
57
61
|
| minuscola | azione immediata, one-shot | |
|
|
58
|
-
| `CTRL`+lettera | idiomi universali
|
|
62
|
+
| `CTRL`+lettera | idiomi universali, toggle dentro i modali di testo, varianti di un'azione | `^F` = find, come ovunque · `^K`/`^P`/`^R` = spawn con un altro prompt |
|
|
59
63
|
| `1`…`9` | voce `launch` n-esima del progetto | da `.claude/loom-works.json` |
|
|
60
64
|
| `q` `esc` | esce dal deck | in un modale `esc` annulla soltanto |
|
|
61
65
|
|
|
@@ -75,6 +79,9 @@ Assegnazioni correnti:
|
|
|
75
79
|
| modale | `S` | sort chain |
|
|
76
80
|
| modale | `F` | filtri |
|
|
77
81
|
| modale | `^F` | **ricerca full-text** nelle conversazioni del progetto |
|
|
82
|
+
| immediata | `^K` | spawna la task selezionata col prompt di **recap** |
|
|
83
|
+
| immediata | `^P` | spawna la task selezionata sul **preflight** |
|
|
84
|
+
| immediata | `^R` | spawna la task selezionata in **esecuzione** |
|
|
78
85
|
| immediata | `f` | **forka** la sessione selezionata (solo col focus sul pane Sessions) |
|
|
79
86
|
| immediata | `t` | terminale @project-root (surface standard launch) |
|
|
80
87
|
| immediata | `c` | sessione Claude **nuda**: nessuna task, nessun prompt iniziale |
|
|
@@ -94,6 +101,34 @@ finestra Ptyxis, senza passare da un modale. `c` (minuscola, azione) e `C`
|
|
|
94
101
|
(maiuscola, modale create-task) restano distinte per la regola sopra — così come
|
|
95
102
|
`f` (fork) e `F` (filtri).
|
|
96
103
|
|
|
104
|
+
### I quattro modi di entrare in una task
|
|
105
|
+
|
|
106
|
+
Sulla task selezionata (focus sul pane Tasks) quattro tasti aprono una sessione
|
|
107
|
+
**bound**: `LOOM_TASK` iniettata, `sessionId` pinnato, binding scritto nel
|
|
108
|
+
sidecar. Fra loro cambia **solo il prompt iniziale**.
|
|
109
|
+
|
|
110
|
+
| Tasto | Prompt |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `⏎` | nessuno — il contesto lo carica l'hook `SessionStart`, il primo messaggio lo scrivi tu |
|
|
113
|
+
| `^K` | `recap stato task <id>` — prompt diretto, nessuna skill |
|
|
114
|
+
| `^P` | `/loom-works:preflight-task <id>` |
|
|
115
|
+
| `^R` | `/loom-works:run-task <id>`, oppure `/loom-works:run-doc <id>` se l'id è una `D` |
|
|
116
|
+
|
|
117
|
+
Il prompt iniziale è la scelta di **chi apre**, non una proprietà della task: lo
|
|
118
|
+
stesso task file si apre per leggerne lo stato, per congelarne le decisioni, per
|
|
119
|
+
eseguirlo o per entrarci a mani nude — e il tasto premuto *è* quell'intento.
|
|
120
|
+
Prima esisteva il solo `⏎` col recap, quindi ogni altro ingresso passava per una
|
|
121
|
+
sessione da correggere a mano.
|
|
122
|
+
|
|
123
|
+
Col focus sul pane Sessions i tre `CTRL` non spawnano: l'oggetto dell'azione è la
|
|
124
|
+
task selezionata, e senza quel pane a fuoco non ce n'è una (il deck lo dice nella
|
|
125
|
+
riga di nota, invece di aprire qualcosa a caso).
|
|
126
|
+
|
|
127
|
+
Il catalogo dei prompt vive in `scripts/deck-run`, non nella TUI: è il primitive
|
|
128
|
+
UI-agnostico, il deck gli passa un **simbolo** (`--prompt-kind`) e non una
|
|
129
|
+
stringa. Così il testo sta in un posto solo, e con lui il suo vincolo di quoting
|
|
130
|
+
— il prompt viaggia dentro apici singoli in `bash -lc`, quindi non può contenerne.
|
|
131
|
+
|
|
97
132
|
### `f` — forkare una conversazione
|
|
98
133
|
|
|
99
134
|
Il fork rama la sessione selezionata: `claude --resume <origine> --fork-session`
|
|
@@ -241,16 +276,21 @@ npx @lamemind/loom-deck
|
|
|
241
276
|
```bash
|
|
242
277
|
# dalla project dir con un tasks.md
|
|
243
278
|
scripts/deck-run T18
|
|
279
|
+
scripts/deck-run T18 --prompt-kind none|recap|preflight|run
|
|
244
280
|
```
|
|
245
281
|
|
|
246
|
-
Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude 'recap stato task T18'
|
|
247
|
-
|
|
282
|
+
Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude 'recap stato task T18'`.
|
|
283
|
+
Il prompt iniziale è il terzo asse dello spawn, ortogonale al binding task
|
|
284
|
+
(`<TaskID>` vs `--no-task`) e alla continuità (nuova vs `--resume`/`--fork`):
|
|
285
|
+
senza flag resta `recap`, `none` apre la sessione bound **senza** alcun prompt.
|
|
286
|
+
`LOOM_DECK_ENTER_PROMPT` (placeholder `{TASK}`) resta un override che vince sul
|
|
287
|
+
kind — tranne su `none`, che è una richiesta esplicita di non averne.
|
|
248
288
|
|
|
249
289
|
## Sviluppo (TUI Ink)
|
|
250
290
|
|
|
251
291
|
```bash
|
|
252
292
|
npm install
|
|
253
|
-
npm run dev # tsx src/cli.tsx — lista tasks.md reale, ↑↓ naviga ·
|
|
293
|
+
npm run dev # tsx src/cli.tsx — lista tasks.md reale, ↑↓ naviga · ⏎/^K/^P/^R spawn · q esci
|
|
254
294
|
npm run build # tsc → dist/
|
|
255
295
|
npm test # node:test sul core vista (src/view.ts), senza Ink né terminale
|
|
256
296
|
```
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { render, Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
4
4
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
@@ -15,7 +15,7 @@ import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSess
|
|
|
15
15
|
import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, rowLabel, selectedSession, stripProjectCore, } from './session-list.js';
|
|
16
16
|
import { launchLegend, loadIdentity, loadLaunch } from './config.js';
|
|
17
17
|
import { isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
|
|
18
|
-
import { cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
|
|
18
|
+
import { caretWindow, cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
|
|
19
19
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
20
20
|
import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
|
|
21
21
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
@@ -35,6 +35,31 @@ const EDIT_ROWS = 4;
|
|
|
35
35
|
function isTextRow(r) {
|
|
36
36
|
return r === 2 || r === 3;
|
|
37
37
|
}
|
|
38
|
+
/** Chiave della bozza scritta dalla riga di testo `r`. */
|
|
39
|
+
function editField(r) {
|
|
40
|
+
return r === 2 ? 'detail' : 'title';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Lunghezza in CODE POINT. Il caret ci indicizza sopra: `.length` conterebbe le
|
|
44
|
+
* code unit UTF-16 e un'emoji nel titolo varrebbe 2 posizioni, cioè un cursore
|
|
45
|
+
* che si ferma a metà glifo e un `slice` che lo spezza in due surrogati.
|
|
46
|
+
*/
|
|
47
|
+
function cpLen(s) {
|
|
48
|
+
return [...s].length;
|
|
49
|
+
}
|
|
50
|
+
/** Inserisce `ins` alla posizione `at` (code point). */
|
|
51
|
+
function insertAt(s, at, ins) {
|
|
52
|
+
const cp = [...s];
|
|
53
|
+
return cp.slice(0, at).join('') + ins + cp.slice(at).join('');
|
|
54
|
+
}
|
|
55
|
+
/** Toglie il code point in posizione `at`; fuori range = stringa invariata. */
|
|
56
|
+
function removeAt(s, at) {
|
|
57
|
+
const cp = [...s];
|
|
58
|
+
if (at < 0 || at >= cp.length)
|
|
59
|
+
return s;
|
|
60
|
+
cp.splice(at, 1);
|
|
61
|
+
return cp.join('');
|
|
62
|
+
}
|
|
38
63
|
// Modale sort a grammatica libera: un tasto per chiave, pressioni successive
|
|
39
64
|
// ciclano asc → desc → fuori dalla chain.
|
|
40
65
|
const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
|
|
@@ -95,8 +120,11 @@ function spawnOut(cmd, args, opts) {
|
|
|
95
120
|
// Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
|
|
96
121
|
// ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
|
|
97
122
|
// sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
|
|
98
|
-
|
|
99
|
-
|
|
123
|
+
// Il kind è OBBLIGATORIO e non ha default qui: il default vive in deck-run (per
|
|
124
|
+
// le invocazioni a mano), mentre dal deck ogni tasto dichiara il proprio intento
|
|
125
|
+
// — un default silenzioso renderebbe indistinguibili `⏎` e `^K`.
|
|
126
|
+
function spawnDeck(id, cwd, sessionId, kind) {
|
|
127
|
+
const child = spawnOut(DECK_RUN, [id, '--session-id', sessionId, '--prompt-kind', kind], {
|
|
100
128
|
cwd,
|
|
101
129
|
detached: true,
|
|
102
130
|
stdio: 'ignore',
|
|
@@ -618,6 +646,36 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
618
646
|
});
|
|
619
647
|
child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
|
|
620
648
|
}
|
|
649
|
+
// T56 — apre una sessione bound alla task selezionata. Punto UNICO dei quattro
|
|
650
|
+
// tasti (⏎/^K/^P/^R): fra loro cambia solo il prompt iniziale, tutto il resto
|
|
651
|
+
// è identico — uuid pinnato, binding scritto PRIMA dello spawn (la sessione
|
|
652
|
+
// risulta figlia della task appena il JSONL compare), handler d'errore, nota.
|
|
653
|
+
// Quattro copie sarebbero quattro posti dove dimenticare il `child.on('error')`,
|
|
654
|
+
// e uno spawn fallito è async: senza handler diventa uncaughtException e
|
|
655
|
+
// ucciderebbe il deck, che invece deve restare vivo.
|
|
656
|
+
// La guardia di focus sta qui e non nei chiamanti perché l'oggetto dell'azione
|
|
657
|
+
// è la task SELEZIONATA: senza il pane task a fuoco non ce n'è una. Per `⏎` il
|
|
658
|
+
// ramo è irraggiungibile (ci arriva già dentro `focus === 'tasks'`); per i tre
|
|
659
|
+
// CTRL, che il ramo `key.ctrl` intercetta globalmente, è l'unico posto.
|
|
660
|
+
function spawnTaskSession(kind, keyLabel) {
|
|
661
|
+
if (focus !== 'tasks') {
|
|
662
|
+
setNote(`${keyLabel} → spawn: seleziona una task (←→ per il pane)`);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (isSpot) {
|
|
666
|
+
setNote('spot: sessioni libere, nessuna task da spawnare');
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (!selTask)
|
|
670
|
+
return;
|
|
671
|
+
const task = selTask;
|
|
672
|
+
const sid = randomUUID();
|
|
673
|
+
appendTaskBinding(cwd, sid, task.id);
|
|
674
|
+
const child = spawnDeck(task.id, cwd, sid, kind);
|
|
675
|
+
child.on('error', () => setNote(`⚠ spawn ${task.id} fallito (${DECK_RUN})`));
|
|
676
|
+
const what = kind === 'none' ? '' : ` · ${kind}`;
|
|
677
|
+
setNote(`${keyLabel} spawn ${task.id}${what} → tab CC (sid ${sid.slice(0, 8)})`);
|
|
678
|
+
}
|
|
621
679
|
// T53 — apertura del modale nota sulla conversazione selezionata. Come
|
|
622
680
|
// `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
|
|
623
681
|
// quasi sempre correggere, e ripartire da vuoto costringerebbe a ridigitare
|
|
@@ -669,6 +727,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
669
727
|
prog,
|
|
670
728
|
detail: initialDetail(detail?.fields['Progress'] ?? '', prog),
|
|
671
729
|
title: selTask.rawDesc,
|
|
730
|
+
// Si apre sulla riga 0 (priorità), che non è un campo di testo: il caret
|
|
731
|
+
// prende la sua posizione entrando in una riga di testo con ↑↓.
|
|
732
|
+
caret: 0,
|
|
672
733
|
});
|
|
673
734
|
setEditRow(0);
|
|
674
735
|
setNote('');
|
|
@@ -1026,11 +1087,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1026
1087
|
else if (key.return) {
|
|
1027
1088
|
submitEdit();
|
|
1028
1089
|
}
|
|
1029
|
-
else if (key.upArrow) {
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1090
|
+
else if (key.upArrow || key.downArrow) {
|
|
1091
|
+
const next = ((editRow + EDIT_ROWS + (key.upArrow ? -1 : 1)) % EDIT_ROWS);
|
|
1092
|
+
setEditRow(next);
|
|
1093
|
+
// Il caret segue la riga attiva e atterra in CODA al nuovo campo: è la
|
|
1094
|
+
// posizione da cui si continua a scrivere, ed è anche l'unica che non
|
|
1095
|
+
// dipende da dove stava il cursore nel campo precedente.
|
|
1096
|
+
setEdit((e) => (e && isTextRow(next) ? { ...e, caret: cpLen(e[editField(next)]) } : e));
|
|
1034
1097
|
}
|
|
1035
1098
|
else if ((key.leftArrow || key.rightArrow) && !isTextRow(editRow)) {
|
|
1036
1099
|
const d = key.leftArrow ? -1 : 1;
|
|
@@ -1049,16 +1112,48 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1049
1112
|
// Un solo ramo per i due campi di testo, la riga sceglie la chiave:
|
|
1050
1113
|
// duplicarlo significherebbe tenere allineate a mano due copie della
|
|
1051
1114
|
// stessa grammatica di input a ogni tasto aggiunto.
|
|
1052
|
-
const field = editRow
|
|
1053
|
-
if (key.
|
|
1054
|
-
|
|
1115
|
+
const field = editField(editRow);
|
|
1116
|
+
if (key.ctrl) {
|
|
1117
|
+
// T54 — ramo CTRL ANTEPOSTO a quelli su carattere, come in modalità
|
|
1118
|
+
// normale: `^A` e `a` arrivano con lo stesso `input`, quindi senza
|
|
1119
|
+
// questa precedenza il `^A` finirebbe dentro il testo.
|
|
1120
|
+
//
|
|
1121
|
+
// `^A`/`^E` (convenzione readline) perché `Home`/`End` NON sono
|
|
1122
|
+
// esposte da `useInput`: arrivano come input vuoto, indistinguibili
|
|
1123
|
+
// da qualunque altro tasto senza nome.
|
|
1124
|
+
//
|
|
1125
|
+
// `^D` è il delete-forward, e anche qui il motivo è un limite di Ink:
|
|
1126
|
+
// il tasto Backspace fisico manda `\x7f` e il tasto Canc manda
|
|
1127
|
+
// `\x1b[3~`, ma `parseKeypress` li battezza ENTRAMBI `delete` e
|
|
1128
|
+
// svuota `input` — a valle sono lo stesso evento. `key.delete` va
|
|
1129
|
+
// quindi al backspace (il tasto che si usa davvero) e la
|
|
1130
|
+
// cancellazione in avanti prende il suo tasto readline.
|
|
1131
|
+
if (input === 'a')
|
|
1132
|
+
setEdit((e) => (e ? { ...e, caret: 0 } : e));
|
|
1133
|
+
else if (input === 'e')
|
|
1134
|
+
setEdit((e) => (e ? { ...e, caret: cpLen(e[field]) } : e));
|
|
1135
|
+
else if (input === 'd')
|
|
1136
|
+
setEdit((e) => (e ? { ...e, [field]: removeAt(e[field], e.caret) } : e));
|
|
1137
|
+
}
|
|
1138
|
+
else if (key.leftArrow || key.rightArrow) {
|
|
1139
|
+
// CLAMP agli estremi, non wrap: a inizio campo `←` non deve saltare in
|
|
1140
|
+
// fondo. Le liste di valori (righe 0/1) ciclano perché sono 3-4 voci;
|
|
1141
|
+
// un testo no — il salto sarebbe indistinguibile da uno sfarfallio.
|
|
1142
|
+
const d = key.leftArrow ? -1 : 1;
|
|
1143
|
+
setEdit((e) => e ? { ...e, caret: Math.max(0, Math.min(cpLen(e[field]), e.caret + d)) } : e);
|
|
1144
|
+
}
|
|
1145
|
+
else if (key.backspace || key.delete) {
|
|
1146
|
+
setEdit((e) => e ? { ...e, [field]: removeAt(e[field], e.caret - 1), caret: Math.max(0, e.caret - 1) } : e);
|
|
1055
1147
|
}
|
|
1056
|
-
else if (input && !key.
|
|
1148
|
+
else if (input && !key.meta) {
|
|
1057
1149
|
// `sanitizeTyped`: `useInput` consegna il CHUNK di stdin, quindi un
|
|
1058
1150
|
// incollaggio porta dentro newline e byte di controllo — invisibili
|
|
1059
1151
|
// nel campo ma contati da Ink nella larghezza della riga, e destinati
|
|
1060
|
-
// a finire tali e quali dentro tasks.md.
|
|
1061
|
-
|
|
1152
|
+
// a finire tali e quali dentro tasks.md. Ed è per lo stesso motivo che
|
|
1153
|
+
// il caret avanza della LUNGHEZZA del chunk, non di uno: un incollaggio
|
|
1154
|
+
// entra tutto insieme.
|
|
1155
|
+
const ins = sanitizeTyped(input);
|
|
1156
|
+
setEdit((e) => e ? { ...e, [field]: insertAt(e[field], e.caret, ins), caret: e.caret + cpLen(ins) } : e);
|
|
1062
1157
|
}
|
|
1063
1158
|
}
|
|
1064
1159
|
return;
|
|
@@ -1076,6 +1171,15 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1076
1171
|
setNote('');
|
|
1077
1172
|
setMode('search');
|
|
1078
1173
|
}
|
|
1174
|
+
else if (input === 'k') {
|
|
1175
|
+
spawnTaskSession('recap', '^K');
|
|
1176
|
+
}
|
|
1177
|
+
else if (input === 'p') {
|
|
1178
|
+
spawnTaskSession('preflight', '^P');
|
|
1179
|
+
}
|
|
1180
|
+
else if (input === 'r') {
|
|
1181
|
+
spawnTaskSession('run', '^R');
|
|
1182
|
+
}
|
|
1079
1183
|
return;
|
|
1080
1184
|
}
|
|
1081
1185
|
if (key.leftArrow || key.rightArrow || key.tab) {
|
|
@@ -1095,21 +1199,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1095
1199
|
}
|
|
1096
1200
|
else if (key.return) {
|
|
1097
1201
|
if (focus === 'tasks') {
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
const sid = randomUUID();
|
|
1105
|
-
appendTaskBinding(cwd, sid, selTask.id);
|
|
1106
|
-
const child = spawnDeck(selTask.id, cwd, sid);
|
|
1107
|
-
// Un errore di spawn è async: senza handler diventa uncaughtException
|
|
1108
|
-
// e ucciderebbe il deck. Lo intercetto per preservare l'invariante
|
|
1109
|
-
// "il deck resta vivo" e mostro la nota d'errore.
|
|
1110
|
-
child.on('error', () => setNote(`⚠ spawn ${selTask.id} fallito (${DECK_RUN})`));
|
|
1111
|
-
setNote(`⏎ spawn ${selTask.id} → tab CC (sid ${sid.slice(0, 8)})`);
|
|
1112
|
-
}
|
|
1202
|
+
// T56 — ⏎ apre la sessione a MANI NUDE: bound alla task (LOOM_TASK,
|
|
1203
|
+
// sessionId pinnato, binding) ma senza messaggio iniettato — il contesto
|
|
1204
|
+
// lo carica l'hook SessionStart, il primo messaggio lo scrive l'utente.
|
|
1205
|
+
// Il recap che ⏎ faceva prima è passato a ^K: resta a un tasto, ma smette
|
|
1206
|
+
// di essere l'unico ingresso possibile nella task.
|
|
1207
|
+
spawnTaskSession('none', '⏎');
|
|
1113
1208
|
}
|
|
1114
1209
|
else {
|
|
1115
1210
|
// T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
|
|
@@ -1345,7 +1440,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1345
1440
|
if (budget.compact) {
|
|
1346
1441
|
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? 'spot', " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
|
|
1347
1442
|
}
|
|
1348
|
-
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 \u00B7", ' ', _jsx(Text, { color: "yellow", children: "
|
|
1443
|
+
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"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7", ' ', canSpawn ? '⏎/^K/^P/^R spawn' : canResume ? '⏎ resume' : '⏎ —', canResume ? ' · f fork' : '', canPin ? ' · p pin · N nota' : '', " \u00B7 ^F cerca \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 t term \u00B7 c claude \u00B7 q esci \u00B7 focus:", ' ', _jsx(Text, { color: "cyan", children: focus })] })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["launch ", 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, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
1349
1444
|
}
|
|
1350
1445
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
1351
1446
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -1368,31 +1463,17 @@ function FilterModal({ view, cursor }) {
|
|
|
1368
1463
|
})] }, row.label)))] }));
|
|
1369
1464
|
}
|
|
1370
1465
|
/**
|
|
1371
|
-
*
|
|
1466
|
+
* Campo di testo del modale edit: finestra ancorata al caret + cursore inverso
|
|
1467
|
+
* nella posizione REALE.
|
|
1372
1468
|
*
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
1376
|
-
*
|
|
1377
|
-
* lo si sta scrivendo. Budget in COLONNE (`termWidth`), non in code unit: è
|
|
1378
|
-
* quello che decide se la riga sfonda il bordo del box.
|
|
1469
|
+
* Il cursore non è più uno spazio inverso appiccicato in coda ma la cella `at`
|
|
1470
|
+
* della finestra — cioè il carattere su cui il caret sta davvero. Fuori fuoco
|
|
1471
|
+
* (`focused` falso) il caret non si disegna e la finestra si ancora in fondo,
|
|
1472
|
+
* che è la vista utile per un campo che non si sta scrivendo.
|
|
1379
1473
|
*/
|
|
1380
|
-
function
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
if (termWidth(s) <= cols)
|
|
1384
|
-
return s;
|
|
1385
|
-
const chars = [...s];
|
|
1386
|
-
let out = '';
|
|
1387
|
-
let w = 1; // la colonna dell'ellissi di testa
|
|
1388
|
-
for (let i = chars.length - 1; i >= 0; i--) {
|
|
1389
|
-
const cw = termWidth(chars[i]);
|
|
1390
|
-
if (w + cw > cols)
|
|
1391
|
-
break;
|
|
1392
|
-
out = chars[i] + out;
|
|
1393
|
-
w += cw;
|
|
1394
|
-
}
|
|
1395
|
-
return `…${out}`;
|
|
1474
|
+
function EditTextField({ label, value, caret, focused, cols, }) {
|
|
1475
|
+
const win = caretWindow(value, focused ? caret : cpLen(value), cols);
|
|
1476
|
+
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) })] }));
|
|
1396
1477
|
}
|
|
1397
1478
|
// T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
|
|
1398
1479
|
// coprirli: la riga che stai modificando resta visibile sopra la lista).
|
|
@@ -1400,14 +1481,14 @@ function tailCut(s, cols) {
|
|
|
1400
1481
|
// del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
|
|
1401
1482
|
function EditModal({ id, draft, row, columns, }) {
|
|
1402
1483
|
const mark = (r) => (row === r ? CARET : CARET_OFF);
|
|
1403
|
-
// Budget
|
|
1484
|
+
// Budget dei campi di testo, DERIVATO da `columns` (mai una costante): il box
|
|
1404
1485
|
// del modale è ANNIDATO nella cornice del deck, quindi le cornici da scalare
|
|
1405
1486
|
// sono due — root (bordo 2 + paddingX 2) e modale (bordo 2 + paddingX 2) — più
|
|
1406
1487
|
// caret 2, etichetta 6, gap 2 e cursore 1. Totale 19.
|
|
1407
1488
|
// Un titolo di tasks.md arriva a ~64 caratteri: senza taglio la riga va a capo
|
|
1408
1489
|
// dentro il box, che si alza di una riga e sfonda il budget verticale (invariante ③).
|
|
1409
|
-
const
|
|
1410
|
-
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(
|
|
1490
|
+
const fieldBudget = Math.max(8, columns - 19);
|
|
1491
|
+
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))] })] }));
|
|
1411
1492
|
}
|
|
1412
1493
|
// T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
|
|
1413
1494
|
// caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
|
package/dist/width.js
CHANGED
|
@@ -202,6 +202,102 @@ export function cut(s, cols) {
|
|
|
202
202
|
// informazione, tolgono una colonna di testo.
|
|
203
203
|
return out.trimEnd().replace(/…$/, '') + '…';
|
|
204
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Larghezza in colonne di UN carattere **dopo** `sanitize`.
|
|
207
|
+
*
|
|
208
|
+
* Misurare il carattere grezzo significa misurare qualcosa che nel frame non
|
|
209
|
+
* entra mai: la sanificazione può cambiarne la larghezza (`✔`, largo 1, diventa
|
|
210
|
+
* `✅`, largo 2 — invariante ①). Chi taglia su un budget deve contare le colonne
|
|
211
|
+
* che verranno DISEGNATE, altrimenti sbaglia di una colonna per ogni glifo
|
|
212
|
+
* sostituito. La cache di `fixChar` rende la doppia chiamata gratuita.
|
|
213
|
+
*/
|
|
214
|
+
export function cellWidth(ch) {
|
|
215
|
+
return termWidth(sanitize(ch));
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Porzione visibile di un campo di testo con un caret MOBILE, larga al più
|
|
219
|
+
* `cols` colonne.
|
|
220
|
+
*
|
|
221
|
+
* Perché non basta un taglio dalla coda: mostrare sempre le ultime `cols`
|
|
222
|
+
* colonne è corretto solo finché si scrive in fondo. Con un caret che si muove,
|
|
223
|
+
* appena rientra a sinistra del bordo visibile si finirebbe per digitare in un
|
|
224
|
+
* punto che non si vede — il testo deve scorrere DIETRO il cursore, non
|
|
225
|
+
* viceversa.
|
|
226
|
+
*
|
|
227
|
+
* Il caret è un indice per CODE POINT (`[...s]`), non per code unit: un'emoji
|
|
228
|
+
* nel titolo sono due code unit e muoversi per unità la spezzerebbe a metà.
|
|
229
|
+
*
|
|
230
|
+
* La finestra si espande ALTERNANDO i due lati, così il cursore resta al centro
|
|
231
|
+
* finché il testo lo consente: muoversi di un carattere fa scorrere il testo di
|
|
232
|
+
* uno, invece di far saltare la vista da un bordo all'altro. Le ellissi entrano
|
|
233
|
+
* nel budget e ne escono da sole — quando un lato tocca il bordo del testo la
|
|
234
|
+
* sua ellissi non serve più e la colonna liberata va all'altro lato.
|
|
235
|
+
*
|
|
236
|
+
* Restituisce i tre pezzi già pronti al render (non la sola stringa visibile):
|
|
237
|
+
* il cursore si disegna invertendo `at`, e ricavarlo tagliando `visible` a
|
|
238
|
+
* `cursorCol` vorrebbe dire rifare qui fuori lo stesso conteggio in colonne.
|
|
239
|
+
*/
|
|
240
|
+
export function caretWindow(text, caret, cols) {
|
|
241
|
+
const empty = { head: '', at: '', tail: '', cursorCol: 0 };
|
|
242
|
+
if (cols <= 0)
|
|
243
|
+
return empty;
|
|
244
|
+
const chars = [...text];
|
|
245
|
+
const c = Math.max(0, Math.min(caret, chars.length));
|
|
246
|
+
// Il caret a fine campo non ha un carattere sotto di sé: gliene si dà uno
|
|
247
|
+
// virtuale, così il cursore ha sempre una cella da invertire e la finestra ha
|
|
248
|
+
// sempre un centro. È lo spazio inverso che il modale disegnava in coda.
|
|
249
|
+
const cells = c === chars.length ? [...chars, ' '] : chars;
|
|
250
|
+
const len = cells.length;
|
|
251
|
+
const w = cells.map(cellWidth);
|
|
252
|
+
const cost = (s, e, tw) => tw + (s > 0 ? 1 : 0) + (e < len ? 1 : 0);
|
|
253
|
+
let start = c;
|
|
254
|
+
let end = c + 1;
|
|
255
|
+
let textW = w[c];
|
|
256
|
+
// Budget più stretto della sola cella del cursore: non c'è finestra da dare.
|
|
257
|
+
if (cost(start, end, textW) > cols)
|
|
258
|
+
return empty;
|
|
259
|
+
let goRight = true;
|
|
260
|
+
let stuckL = false;
|
|
261
|
+
let stuckR = false;
|
|
262
|
+
while (!stuckL || !stuckR) {
|
|
263
|
+
const canR = end < len && !stuckR;
|
|
264
|
+
const canL = start > 0 && !stuckL;
|
|
265
|
+
if (!canR && !canL)
|
|
266
|
+
break;
|
|
267
|
+
if (canR && (goRight || !canL)) {
|
|
268
|
+
const nw = textW + w[end];
|
|
269
|
+
if (cost(start, end + 1, nw) > cols)
|
|
270
|
+
stuckR = true;
|
|
271
|
+
else {
|
|
272
|
+
textW = nw;
|
|
273
|
+
end++;
|
|
274
|
+
// Arrivare in fondo toglie l'ellissi di coda: la colonna che si libera
|
|
275
|
+
// può rimettere in gioco il lato che si era fermato per un pelo.
|
|
276
|
+
if (end === len)
|
|
277
|
+
stuckL = false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const nw = textW + w[start - 1];
|
|
282
|
+
if (cost(start - 1, end, nw) > cols)
|
|
283
|
+
stuckL = true;
|
|
284
|
+
else {
|
|
285
|
+
textW = nw;
|
|
286
|
+
start--;
|
|
287
|
+
if (start === 0)
|
|
288
|
+
stuckR = false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
goRight = !goRight;
|
|
292
|
+
}
|
|
293
|
+
const head = (start > 0 ? '…' : '') + cells.slice(start, c).join('');
|
|
294
|
+
return {
|
|
295
|
+
head,
|
|
296
|
+
at: cells[c],
|
|
297
|
+
tail: cells.slice(c + 1, end).join('') + (end < len ? '…' : ''),
|
|
298
|
+
cursorCol: termWidth(sanitize(head)),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
205
301
|
/**
|
|
206
302
|
* Indice sorgente raggiunto consumando al più `cols` colonne da `from`.
|
|
207
303
|
*
|
package/package.json
CHANGED
package/scripts/deck-run
CHANGED
|
@@ -4,8 +4,20 @@
|
|
|
4
4
|
#
|
|
5
5
|
# Apre una tab Ptyxis nella window ATTIVA (quella col focus = il deck) e vi
|
|
6
6
|
# avvia una sessione Claude Code già bound alla task via LOOM_TASK, dritta sul
|
|
7
|
-
# prompt iniziale (default: prompt diretto
|
|
8
|
-
#
|
|
7
|
+
# prompt iniziale scelto con --prompt-kind (default: `recap`, prompt diretto
|
|
8
|
+
# "recap stato task <TaskID>" — no skill, solo recap → più controllo).
|
|
9
|
+
# Riusabile identico da TUI (Ink) e da web.
|
|
10
|
+
#
|
|
11
|
+
# Tre assi ORTOGONALI, da non confondere fra loro:
|
|
12
|
+
# binding task <TaskID> ..... --no-task
|
|
13
|
+
# continuità nuova ........ --resume [--fork]
|
|
14
|
+
# prompt --prompt-kind none|recap|preflight|run
|
|
15
|
+
# Il terzo asse (T56) prima non esisteva: il prompt era una CONSEGUENZA degli
|
|
16
|
+
# altri due (bound ⇒ recap, --no-task ⇒ niente, --resume ⇒ niente), quindi
|
|
17
|
+
# "task-bound SENZA prompt" era inesprimibile e l'unico modo di non avere un
|
|
18
|
+
# prompt era perdere la task. Il catalogo dei prompt vive qui e non nel deck
|
|
19
|
+
# perché questo è il primitive UI-agnostico: il chiamante passa un SIMBOLO, non
|
|
20
|
+
# una stringa — il quoting resta verificato in un posto solo (vedi PROMPT sotto).
|
|
9
21
|
#
|
|
10
22
|
# Con --no-task la sessione è NUDA: niente LOOM_TASK, niente prompt iniziale,
|
|
11
23
|
# niente --session-id. Serve al lavoro spot che non appartiene a nessuna task.
|
|
@@ -26,7 +38,7 @@
|
|
|
26
38
|
# LOOM_DECK_SPAWN_MODE inline|profile (default: inline)
|
|
27
39
|
# LOOM_DECK_PROFILE_UUID UUID profilo Ptyxis (default: cc-host)
|
|
28
40
|
# LOOM_DECK_WORKDIR dir di lavoro della tab (default: $PWD)
|
|
29
|
-
# LOOM_DECK_ENTER_PROMPT prompt iniziale
|
|
41
|
+
# LOOM_DECK_ENTER_PROMPT override del prompt iniziale, {TASK}=TaskID (vince sul kind, tranne `none`)
|
|
30
42
|
# LOOM_DECK_INTAB_CMD override comando in-tab (default: la sessione CC; usato per i test)
|
|
31
43
|
# LOOM_DECK_PERMISSION_MODE override del permissionMode del file config (default: campo file, poi 'manual')
|
|
32
44
|
#
|
|
@@ -37,6 +49,10 @@ SESSION_ID=""
|
|
|
37
49
|
RESUME_ID=""
|
|
38
50
|
NO_TASK=0
|
|
39
51
|
FORK=0
|
|
52
|
+
# Vuoto = flag non passato (≠ un default già risolto): serve a distinguere
|
|
53
|
+
# "kind implicito" da "kind chiesto", perché con --no-task il primo è legittimo
|
|
54
|
+
# (nessun prompt, come sempre) e il secondo è un errore d'uso.
|
|
55
|
+
PROMPT_KIND=""
|
|
40
56
|
# Positional <TaskID> + flag opzionale --session-id <uuid> (T27): il deck genera
|
|
41
57
|
# l'UUID e lo pinna così il binding sidecar sessionId↔taskId è deterministico.
|
|
42
58
|
# --no-task (T42): modalità NUDA, senza TaskID — nessuna LOOM_TASK, nessun prompt
|
|
@@ -55,8 +71,17 @@ FORK=0
|
|
|
55
71
|
# Sotto --fork il --session-id torna AMMESSO (anzi: è il punto) — la mutua
|
|
56
72
|
# esclusione con --resume esiste perché una ripresa nuda riscrive il transcript
|
|
57
73
|
# dell'id ripreso, mentre il fork ne apre uno NUOVO, che possiamo quindi pinnare.
|
|
74
|
+
# --prompt-kind <none|recap|preflight|run> (T56): sceglie il prompt iniziale fra
|
|
75
|
+
# quelli del catalogo qui sotto. Enum e non stringa libera: il prompt viaggia
|
|
76
|
+
# dentro apici singoli in `bash -lc`, quindi il testo va tenuto in un posto solo
|
|
77
|
+
# e verificato una volta, invece di spostare il rischio di quoting su ogni
|
|
78
|
+
# chiamante. Assente → `recap`, così ogni invocazione preesistente è invariata.
|
|
58
79
|
while [[ $# -gt 0 ]]; do
|
|
59
80
|
case "$1" in
|
|
81
|
+
--prompt-kind)
|
|
82
|
+
PROMPT_KIND="${2:-}"; shift 2 ;;
|
|
83
|
+
--prompt-kind=*)
|
|
84
|
+
PROMPT_KIND="${1#*=}"; shift ;;
|
|
60
85
|
--session-id)
|
|
61
86
|
SESSION_ID="${2:-}"; shift 2 ;;
|
|
62
87
|
--session-id=*)
|
|
@@ -75,12 +100,19 @@ while [[ $# -gt 0 ]]; do
|
|
|
75
100
|
esac
|
|
76
101
|
done
|
|
77
102
|
|
|
78
|
-
USAGE="uso: deck-run <TaskID> [--session-id <uuid>]
|
|
103
|
+
USAGE="uso: deck-run <TaskID> [--prompt-kind <kind>] [--session-id <uuid>]
|
|
104
|
+
(es. deck-run T18)
|
|
79
105
|
| deck-run --no-task (sessione nuda, senza task)
|
|
80
106
|
| deck-run <TaskID> --resume <uuid> (riprende sessione scoped)
|
|
81
107
|
| deck-run --no-task --resume <uuid> (riprende sessione spot)
|
|
82
108
|
| deck-run <TaskID> --resume <uuid> --fork [--session-id <nuovo-uuid>]
|
|
83
|
-
(forka: ramo con id NUOVO)
|
|
109
|
+
(forka: ramo con id NUOVO)
|
|
110
|
+
|
|
111
|
+
--prompt-kind prompt iniziale della sessione bound (default: recap)
|
|
112
|
+
none nessun prompt — sessione aperta sulla task, a mani nude
|
|
113
|
+
recap recap stato task <TaskID>
|
|
114
|
+
preflight /loom-works:preflight-task <TaskID>
|
|
115
|
+
run /loom-works:run-task <TaskID> (run-doc sulle task D)"
|
|
84
116
|
|
|
85
117
|
if [[ $NO_TASK -eq 1 && -n "$TASK" ]]; then
|
|
86
118
|
echo "--no-task e <TaskID> sono mutuamente esclusivi" >&2
|
|
@@ -102,6 +134,25 @@ if [[ -n "$RESUME_ID" && -n "$SESSION_ID" && $FORK -eq 0 ]]; then
|
|
|
102
134
|
echo "$USAGE" >&2
|
|
103
135
|
exit 2
|
|
104
136
|
fi
|
|
137
|
+
# Un kind fuori catalogo è un errore d'uso del CHIAMANTE (simbolo interno, non
|
|
138
|
+
# configurazione utente) → exit, non fallback silenzioso come permissionMode:
|
|
139
|
+
# lì il valore arriva da un file editabile a mano, qui da un argomento nostro.
|
|
140
|
+
if [[ -n "$PROMPT_KIND" ]]; then
|
|
141
|
+
case "$PROMPT_KIND" in
|
|
142
|
+
none|recap|preflight|run) ;;
|
|
143
|
+
*) echo "--prompt-kind ignoto: '${PROMPT_KIND}' (usa none|recap|preflight|run)" >&2
|
|
144
|
+
echo "$USAGE" >&2
|
|
145
|
+
exit 2 ;;
|
|
146
|
+
esac
|
|
147
|
+
fi
|
|
148
|
+
# Ogni prompt del catalogo nomina il TaskID: senza task non c'è nulla da
|
|
149
|
+
# chiedere, e un `--no-task --prompt-kind run` è una richiesta contraddittoria,
|
|
150
|
+
# non una da degradare in silenzio.
|
|
151
|
+
if [[ $NO_TASK -eq 1 && -n "$PROMPT_KIND" ]]; then
|
|
152
|
+
echo "--prompt-kind richiede una task: una sessione --no-task non ha prompt iniziale" >&2
|
|
153
|
+
echo "$USAGE" >&2
|
|
154
|
+
exit 2
|
|
155
|
+
fi
|
|
105
156
|
|
|
106
157
|
MODE="${LOOM_DECK_SPAWN_MODE:-inline}"
|
|
107
158
|
PROFILE_UUID="${LOOM_DECK_PROFILE_UUID:-5a36ae48df1c4d4882f43060e3e59656}"
|
|
@@ -189,18 +240,42 @@ RES_FLAG=""
|
|
|
189
240
|
FORK_FLAG=""
|
|
190
241
|
[[ $FORK -eq 1 ]] && FORK_FLAG="--fork-session "
|
|
191
242
|
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
|
|
243
|
+
# ── Catalogo dei prompt iniziali (T56) ───────────────────────────────────────
|
|
244
|
+
# Il prompt iniziale è la scelta di CHI APRE la sessione, non una proprietà della
|
|
245
|
+
# task: lo stesso task file si apre per leggerne lo stato, per un preflight, per
|
|
246
|
+
# eseguirlo o a mani nude. Il kind è quell'intento.
|
|
247
|
+
# none → nessun prompt (≠ stringa vuota: PROMPT_ARG sparisce del tutto,
|
|
248
|
+
# come sul ramo --resume, altrimenti CC riceve un posizionale vuoto)
|
|
249
|
+
# recap → prompt DIRETTO, no skill: l'utente rivede/steera prima di lanciare
|
|
250
|
+
# preflight → skill di preflight sulla task
|
|
251
|
+
# run → skill di esecuzione sulla task
|
|
252
|
+
# Vincolo di quoting: nessun template deve contenere apici singoli (viaggia
|
|
253
|
+
# dentro '...' nel comando in-tab). `/loom-works:...` passa intero proprio perché
|
|
254
|
+
# è single-quoted — lo slash e i due punti non sono metacaratteri lì dentro.
|
|
255
|
+
#
|
|
256
|
+
# Precedenza con l'env: LOOM_DECK_ENTER_PROMPT VINCE sul kind (retro-compat: chi
|
|
257
|
+
# lo usa oggi non si accorge del cambio), tranne su `none` — chiedere
|
|
258
|
+
# esplicitamente nessun prompt non è una richiesta che un default d'ambiente
|
|
259
|
+
# possa scavalcare. Il placeholder {TASK} vale solo per il valore dell'override;
|
|
260
|
+
# i template del catalogo interpolano ${TASK} diretto (nel `${VAR:-...}` la `}`
|
|
261
|
+
# di `{TASK}` chiuderebbe in anticipo l'espansione).
|
|
262
|
+
# `run` DISPATCHA sul prefisso: la lista del deck contiene entrambe le famiglie
|
|
263
|
+
# del contratto loom (`T` code, `D` doc) e le due hanno skill di esecuzione
|
|
264
|
+
# diverse — `run-task` è dichiarata code-only, `run-doc` è la sua complementare.
|
|
265
|
+
# Su una D il prompt `run-task` invocherebbe la skill sbagliata. Gli altri kind
|
|
266
|
+
# non discriminano: `recap` è un prompt diretto (nessuna skill) e
|
|
267
|
+
# `preflight-task` risolve il task file per id, indifferente al prefisso.
|
|
268
|
+
case "${PROMPT_KIND:-recap}" in
|
|
269
|
+
none) PROMPT="" ;;
|
|
270
|
+
recap) PROMPT="recap stato task ${TASK}" ;;
|
|
271
|
+
preflight) PROMPT="/loom-works:preflight-task ${TASK}" ;;
|
|
272
|
+
run) case "$TASK" in
|
|
273
|
+
D*) PROMPT="/loom-works:run-doc ${TASK}" ;;
|
|
274
|
+
*) PROMPT="/loom-works:run-task ${TASK}" ;;
|
|
275
|
+
esac ;;
|
|
276
|
+
esac
|
|
277
|
+
if [[ "${PROMPT_KIND:-}" != "none" && -n "${LOOM_DECK_ENTER_PROMPT:-}" ]]; then
|
|
201
278
|
PROMPT="${LOOM_DECK_ENTER_PROMPT//\{TASK\}/${TASK}}"
|
|
202
|
-
else
|
|
203
|
-
PROMPT="recap stato task ${TASK}"
|
|
204
279
|
fi
|
|
205
280
|
|
|
206
281
|
# Comando eseguito dentro la tab: bind LOOM_TASK per la sessione, avvia CC sul PROMPT.
|
|
@@ -213,8 +288,10 @@ fi
|
|
|
213
288
|
# Su resume (T49) il prompt iniziale salta anche nel ramo task: riprendere una
|
|
214
289
|
# conversazione significa continuarla, non iniettarle un nuovo messaggio. Il
|
|
215
290
|
# fork (T28) ricade nello stesso ramo — è una ripresa, con id nuovo.
|
|
216
|
-
|
|
217
|
-
|
|
291
|
+
# Il kind `none` (T56) atterra sulla stessa assenza per una ragione diversa: lì
|
|
292
|
+
# la sessione è nuova e bound, ma l'utente ha chiesto di entrarci a mani nude.
|
|
293
|
+
PROMPT_ARG=""
|
|
294
|
+
[[ -n "$PROMPT" && -z "$RESUME_ID" ]] && PROMPT_ARG="'${PROMPT}'"
|
|
218
295
|
# Il SID_FLAG entra anche nel ramo --no-task, che altrimenti lo scarta insieme
|
|
219
296
|
# agli altri elementi task-bound: nel fork di una sessione SPOT il sessionId
|
|
220
297
|
# pinnato non serve a legare una task (non c'è) ma a rendere noto in anticipo
|