@vincemakes/kiso-tui 0.5.0 → 0.7.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/dist/at-picker.d.ts +159 -0
- package/dist/at-picker.js +228 -0
- package/dist/compositor.d.ts +15 -0
- package/dist/compositor.js +40 -1
- package/dist/editor.d.ts +20 -0
- package/dist/editor.js +297 -18
- package/dist/index.d.ts +3 -0
- package/dist/index.js +10 -0
- package/dist/status.d.ts +38 -0
- package/dist/status.js +52 -0
- package/dist/strings.d.ts +6 -0
- package/dist/strings.js +6 -0
- package/package.json +2 -2
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KC3 §3 — the @ file picker's PURE half: the subsequence filter and
|
|
3
|
+
* the deterministic rank. No scoring library, no index, no disk. The
|
|
4
|
+
* file list is DATA the CLI feeds in (the tui purity rule: input is
|
|
5
|
+
* data, output is bytes); this module decides which of those paths a
|
|
6
|
+
* query matches, in what order, and which characters to embolden.
|
|
7
|
+
*
|
|
8
|
+
* Determinism is the whole design constraint. A fuzzy finder that
|
|
9
|
+
* reorders on a tie is unusable at speed — the row under the cursor
|
|
10
|
+
* must not move because two paths scored equal. Every comparison here
|
|
11
|
+
* ends in a total order: run length, then path length, then the raw
|
|
12
|
+
* lexical order of the path (never localeCompare, whose result depends
|
|
13
|
+
* on the machine's locale).
|
|
14
|
+
*/
|
|
15
|
+
/** The bound source's item — a repo-relative path and nothing else.
|
|
16
|
+
* Structural: the CLI passes whatever it likes as long as it has a
|
|
17
|
+
* path (slice 5 passes exactly this). */
|
|
18
|
+
export interface AtItem {
|
|
19
|
+
readonly path: string;
|
|
20
|
+
}
|
|
21
|
+
/** A matched path plus the indices the panel emboldens. */
|
|
22
|
+
export interface AtMatch {
|
|
23
|
+
readonly path: string;
|
|
24
|
+
/** the matched character positions, ascending — the panel renders
|
|
25
|
+
* these bold-white and the rest dim */
|
|
26
|
+
readonly hit: readonly number[];
|
|
27
|
+
/** the longest CONTIGUOUS run inside `hit` — the rank's first key,
|
|
28
|
+
* carried so the panel and the ranking can never disagree about
|
|
29
|
+
* why a row is where it is */
|
|
30
|
+
readonly run: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* KC3 §5 — the ONE cap. The file list is computed per open with no
|
|
34
|
+
* index and no watcher, so its cost is bounded here rather than
|
|
35
|
+
* amortized somewhere invisible. The source collects at most CAP + 1
|
|
36
|
+
* entries: the extra one is what makes "there were more" DISTINGUISHABLE
|
|
37
|
+
* from "there were exactly this many", so the counter row can say so
|
|
38
|
+
* honestly instead of guessing.
|
|
39
|
+
*/
|
|
40
|
+
export declare const AT_CAP = 2000;
|
|
41
|
+
/**
|
|
42
|
+
* KC3 §5 — the directories the picker never offers, and the other half
|
|
43
|
+
* of its contract with whatever host has to walk a tree to fill it.
|
|
44
|
+
*
|
|
45
|
+
* It lives here beside the cap because the two are the same kind of
|
|
46
|
+
* promise: a host that walks must prune these BEFORE descending (a
|
|
47
|
+
* post-filter would already have walked node_modules, which is the
|
|
48
|
+
* cost the pruning exists to avoid), and must stop at the cap. Hosts
|
|
49
|
+
* that get their list from a VCS ignore this set entirely — the VCS
|
|
50
|
+
* has already applied a better one.
|
|
51
|
+
*/
|
|
52
|
+
export declare const AT_SKIP: ReadonlySet<string>;
|
|
53
|
+
/** KC3 §4 — the panel's visible height. A ceiling, not a promise: the
|
|
54
|
+
* compositor clamps further when the terminal is short. */
|
|
55
|
+
export declare const AT_VISIBLE = 5;
|
|
56
|
+
/**
|
|
57
|
+
* The subsequence embedding, TIGHTENED — the two-pass walk every good
|
|
58
|
+
* fuzzy finder uses, and the reason `@ra` emboldens the "ra" of
|
|
59
|
+
* "src/range.js" rather than the r of "src" and the a of "range".
|
|
60
|
+
*
|
|
61
|
+
* Pass 1 walks forward and stops at the EARLIEST index that completes
|
|
62
|
+
* the query — this both answers "does it match at all" and fixes the
|
|
63
|
+
* right-hand edge. Pass 2 walks backward from that edge, taking the
|
|
64
|
+
* LATEST position for each query character in turn, which slides every
|
|
65
|
+
* matched character as far right as it can go without crossing the
|
|
66
|
+
* next one. The result is the most clustered embedding that ends where
|
|
67
|
+
* the earliest match ends.
|
|
68
|
+
*
|
|
69
|
+
* Case-insensitive: both sides are lowercased by the caller once per
|
|
70
|
+
* query rather than once per character.
|
|
71
|
+
*
|
|
72
|
+
* Returns the ascending match indices, or null when the query is not a
|
|
73
|
+
* subsequence of the path at all.
|
|
74
|
+
*/
|
|
75
|
+
export declare function atEmbed(lowerPath: string, lowerQuery: string): number[] | null;
|
|
76
|
+
/** The longest run of CONSECUTIVE indices in an ascending list. An
|
|
77
|
+
* empty query has no run — every path ties on it, and the rank falls
|
|
78
|
+
* through to path length. */
|
|
79
|
+
export declare function longestRun(hit: readonly number[]): number;
|
|
80
|
+
/**
|
|
81
|
+
* The filter + the rank. Case-insensitive SUBSEQUENCE over the FULL
|
|
82
|
+
* relative path (so `@tui/ed` finds packages/tui/src/editor.ts — the
|
|
83
|
+
* directory is part of what the user is typing at, not a separate
|
|
84
|
+
* field), ordered by:
|
|
85
|
+
*
|
|
86
|
+
* 1. contiguous-run length DESC — a path where the query appears as
|
|
87
|
+
* a solid stretch beats one where it is scattered across the
|
|
88
|
+
* whole string. This is the key that makes typing feel like
|
|
89
|
+
* aiming rather than fishing.
|
|
90
|
+
* 2. path length ASC — among equally solid hits, the shorter path is
|
|
91
|
+
* the more likely target (src/range.js over a deep vendored copy
|
|
92
|
+
* of the same name).
|
|
93
|
+
* 3. the path itself, lexically — the tiebreak of last resort, and
|
|
94
|
+
* the reason the order NEVER depends on the source's iteration
|
|
95
|
+
* order or on two runs of the same query disagreeing.
|
|
96
|
+
*
|
|
97
|
+
* An EMPTY query matches everything: rule 1 ties at 0 for all, so the
|
|
98
|
+
* listing is shortest-path-first, then lexical. The list is sliced to
|
|
99
|
+
* AT_CAP; `capped` reports whether anything was dropped.
|
|
100
|
+
*/
|
|
101
|
+
export declare function atFilter(items: readonly AtItem[], query: string): {
|
|
102
|
+
matches: AtMatch[];
|
|
103
|
+
capped: boolean;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* KC3 §4 — the picker's WINDOW: which slice of the ranked list is on
|
|
107
|
+
* screen. The window TRAILS the selection exactly as the composer's
|
|
108
|
+
* own viewport trails the cursor (KC1 §5) — derived per read, never
|
|
109
|
+
* stored, so it can never disagree with the selection it is meant to
|
|
110
|
+
* follow.
|
|
111
|
+
*/
|
|
112
|
+
export declare function atWindow(total: number, selected: number, visible?: number): {
|
|
113
|
+
first: number;
|
|
114
|
+
count: number;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* KC3 §4 — ONE row of the panel.
|
|
118
|
+
*
|
|
119
|
+
* Two columns: the file's NAME on the left with its matched characters
|
|
120
|
+
* bold, and the DIRECTORY dim on the right, pushed to the far edge.
|
|
121
|
+
* The name is what the user is aiming at; the directory is what tells
|
|
122
|
+
* two same-named files apart, which is why it is present but quiet.
|
|
123
|
+
*
|
|
124
|
+
* The selected row carries `→` on the inverse band (SGR 7, closed with
|
|
125
|
+
* 27 — never SGR 0, so it composes inside the row's own spans).
|
|
126
|
+
*
|
|
127
|
+
* The `hit` indices are over the FULL path, so they are shifted by the
|
|
128
|
+
* directory's length to land on the name. A hit that falls INSIDE the
|
|
129
|
+
* directory is simply not drawn bold — the directory column is
|
|
130
|
+
* uniformly dim by design (a bold fragment in a right-aligned dim
|
|
131
|
+
* column reads as damage, not as information).
|
|
132
|
+
*
|
|
133
|
+
* The row never exceeds W: the name cuts first (it is the flexible
|
|
134
|
+
* column), and the directory is dropped entirely before the name is
|
|
135
|
+
* cut to nothing.
|
|
136
|
+
*/
|
|
137
|
+
export declare function atRow(match: AtMatch, selected: boolean, W: number): string;
|
|
138
|
+
/**
|
|
139
|
+
* KC3 §4 — the counter row: `(n/total)`, where n is the 1-based
|
|
140
|
+
* position of the SELECTION in the whole ranked list, not in the
|
|
141
|
+
* visible window. The user needs to know where they are in the list,
|
|
142
|
+
* which the five visible rows cannot tell them.
|
|
143
|
+
*
|
|
144
|
+
* When the source list was truncated the row SAYS SO. A file picker
|
|
145
|
+
* that quietly lists 2,000 of 40,000 files and shows a confident
|
|
146
|
+
* "(3/1998)" is lying by omission; this one admits the horizon.
|
|
147
|
+
*/
|
|
148
|
+
export declare function atCounterRow(selected: number, total: number, capped: boolean, W: number): string;
|
|
149
|
+
/**
|
|
150
|
+
* KC3 §4 — the whole band: at most AT_VISIBLE windowed rows, then the
|
|
151
|
+
* counter. Returned as plain strings for the menu-rows channel, which
|
|
152
|
+
* already accounts them in chromeRows — the picker needs no geometry
|
|
153
|
+
* of its own, which is the entire reason it rides that channel.
|
|
154
|
+
*/
|
|
155
|
+
export declare function atPanelRows(state: {
|
|
156
|
+
matches: readonly AtMatch[];
|
|
157
|
+
selected: number;
|
|
158
|
+
capped: boolean;
|
|
159
|
+
}, W: number): string[];
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KC3 §3 — the @ file picker's PURE half: the subsequence filter and
|
|
3
|
+
* the deterministic rank. No scoring library, no index, no disk. The
|
|
4
|
+
* file list is DATA the CLI feeds in (the tui purity rule: input is
|
|
5
|
+
* data, output is bytes); this module decides which of those paths a
|
|
6
|
+
* query matches, in what order, and which characters to embolden.
|
|
7
|
+
*
|
|
8
|
+
* Determinism is the whole design constraint. A fuzzy finder that
|
|
9
|
+
* reorders on a tie is unusable at speed — the row under the cursor
|
|
10
|
+
* must not move because two paths scored equal. Every comparison here
|
|
11
|
+
* ends in a total order: run length, then path length, then the raw
|
|
12
|
+
* lexical order of the path (never localeCompare, whose result depends
|
|
13
|
+
* on the machine's locale).
|
|
14
|
+
*/
|
|
15
|
+
import { escapeTerminal, palette } from "./render.js";
|
|
16
|
+
import { visibleWidth, widthCut } from "./components.js";
|
|
17
|
+
/**
|
|
18
|
+
* KC3 §5 — the ONE cap. The file list is computed per open with no
|
|
19
|
+
* index and no watcher, so its cost is bounded here rather than
|
|
20
|
+
* amortized somewhere invisible. The source collects at most CAP + 1
|
|
21
|
+
* entries: the extra one is what makes "there were more" DISTINGUISHABLE
|
|
22
|
+
* from "there were exactly this many", so the counter row can say so
|
|
23
|
+
* honestly instead of guessing.
|
|
24
|
+
*/
|
|
25
|
+
export const AT_CAP = 2000;
|
|
26
|
+
/**
|
|
27
|
+
* KC3 §5 — the directories the picker never offers, and the other half
|
|
28
|
+
* of its contract with whatever host has to walk a tree to fill it.
|
|
29
|
+
*
|
|
30
|
+
* It lives here beside the cap because the two are the same kind of
|
|
31
|
+
* promise: a host that walks must prune these BEFORE descending (a
|
|
32
|
+
* post-filter would already have walked node_modules, which is the
|
|
33
|
+
* cost the pruning exists to avoid), and must stop at the cap. Hosts
|
|
34
|
+
* that get their list from a VCS ignore this set entirely — the VCS
|
|
35
|
+
* has already applied a better one.
|
|
36
|
+
*/
|
|
37
|
+
export const AT_SKIP = new Set([".git", "node_modules", "dist", "build", "coverage"]);
|
|
38
|
+
/** KC3 §4 — the panel's visible height. A ceiling, not a promise: the
|
|
39
|
+
* compositor clamps further when the terminal is short. */
|
|
40
|
+
export const AT_VISIBLE = 5;
|
|
41
|
+
/**
|
|
42
|
+
* The subsequence embedding, TIGHTENED — the two-pass walk every good
|
|
43
|
+
* fuzzy finder uses, and the reason `@ra` emboldens the "ra" of
|
|
44
|
+
* "src/range.js" rather than the r of "src" and the a of "range".
|
|
45
|
+
*
|
|
46
|
+
* Pass 1 walks forward and stops at the EARLIEST index that completes
|
|
47
|
+
* the query — this both answers "does it match at all" and fixes the
|
|
48
|
+
* right-hand edge. Pass 2 walks backward from that edge, taking the
|
|
49
|
+
* LATEST position for each query character in turn, which slides every
|
|
50
|
+
* matched character as far right as it can go without crossing the
|
|
51
|
+
* next one. The result is the most clustered embedding that ends where
|
|
52
|
+
* the earliest match ends.
|
|
53
|
+
*
|
|
54
|
+
* Case-insensitive: both sides are lowercased by the caller once per
|
|
55
|
+
* query rather than once per character.
|
|
56
|
+
*
|
|
57
|
+
* Returns the ascending match indices, or null when the query is not a
|
|
58
|
+
* subsequence of the path at all.
|
|
59
|
+
*/
|
|
60
|
+
export function atEmbed(lowerPath, lowerQuery) {
|
|
61
|
+
if (lowerQuery === "")
|
|
62
|
+
return [];
|
|
63
|
+
// pass 1 — forward, to the earliest completing index
|
|
64
|
+
let qi = 0;
|
|
65
|
+
let end = -1;
|
|
66
|
+
for (let pi = 0; pi < lowerPath.length; pi += 1) {
|
|
67
|
+
if (lowerPath[pi] === lowerQuery[qi]) {
|
|
68
|
+
qi += 1;
|
|
69
|
+
if (qi === lowerQuery.length) {
|
|
70
|
+
end = pi;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (end === -1)
|
|
76
|
+
return null; // not a subsequence — no embedding exists
|
|
77
|
+
// pass 2 — backward from that edge, sliding each character right
|
|
78
|
+
const hit = new Array(lowerQuery.length);
|
|
79
|
+
let pi = end;
|
|
80
|
+
for (let j = lowerQuery.length - 1; j >= 0; j -= 1) {
|
|
81
|
+
while (lowerPath[pi] !== lowerQuery[j])
|
|
82
|
+
pi -= 1;
|
|
83
|
+
hit[j] = pi;
|
|
84
|
+
pi -= 1;
|
|
85
|
+
}
|
|
86
|
+
return hit;
|
|
87
|
+
}
|
|
88
|
+
/** The longest run of CONSECUTIVE indices in an ascending list. An
|
|
89
|
+
* empty query has no run — every path ties on it, and the rank falls
|
|
90
|
+
* through to path length. */
|
|
91
|
+
export function longestRun(hit) {
|
|
92
|
+
let best = 0;
|
|
93
|
+
let run = 0;
|
|
94
|
+
for (let i = 0; i < hit.length; i += 1) {
|
|
95
|
+
run = i > 0 && hit[i] === hit[i - 1] + 1 ? run + 1 : 1;
|
|
96
|
+
if (run > best)
|
|
97
|
+
best = run;
|
|
98
|
+
}
|
|
99
|
+
return best;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The filter + the rank. Case-insensitive SUBSEQUENCE over the FULL
|
|
103
|
+
* relative path (so `@tui/ed` finds packages/tui/src/editor.ts — the
|
|
104
|
+
* directory is part of what the user is typing at, not a separate
|
|
105
|
+
* field), ordered by:
|
|
106
|
+
*
|
|
107
|
+
* 1. contiguous-run length DESC — a path where the query appears as
|
|
108
|
+
* a solid stretch beats one where it is scattered across the
|
|
109
|
+
* whole string. This is the key that makes typing feel like
|
|
110
|
+
* aiming rather than fishing.
|
|
111
|
+
* 2. path length ASC — among equally solid hits, the shorter path is
|
|
112
|
+
* the more likely target (src/range.js over a deep vendored copy
|
|
113
|
+
* of the same name).
|
|
114
|
+
* 3. the path itself, lexically — the tiebreak of last resort, and
|
|
115
|
+
* the reason the order NEVER depends on the source's iteration
|
|
116
|
+
* order or on two runs of the same query disagreeing.
|
|
117
|
+
*
|
|
118
|
+
* An EMPTY query matches everything: rule 1 ties at 0 for all, so the
|
|
119
|
+
* listing is shortest-path-first, then lexical. The list is sliced to
|
|
120
|
+
* AT_CAP; `capped` reports whether anything was dropped.
|
|
121
|
+
*/
|
|
122
|
+
export function atFilter(items, query) {
|
|
123
|
+
const capped = items.length > AT_CAP;
|
|
124
|
+
const pool = capped ? items.slice(0, AT_CAP) : items;
|
|
125
|
+
const lowerQuery = query.toLowerCase();
|
|
126
|
+
const matches = [];
|
|
127
|
+
for (const item of pool) {
|
|
128
|
+
const hit = atEmbed(item.path.toLowerCase(), lowerQuery);
|
|
129
|
+
if (hit === null)
|
|
130
|
+
continue;
|
|
131
|
+
matches.push({ path: item.path, hit, run: longestRun(hit) });
|
|
132
|
+
}
|
|
133
|
+
matches.sort((a, b) => {
|
|
134
|
+
if (a.run !== b.run)
|
|
135
|
+
return b.run - a.run;
|
|
136
|
+
if (a.path.length !== b.path.length)
|
|
137
|
+
return a.path.length - b.path.length;
|
|
138
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
139
|
+
});
|
|
140
|
+
return { matches, capped };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* KC3 §4 — the picker's WINDOW: which slice of the ranked list is on
|
|
144
|
+
* screen. The window TRAILS the selection exactly as the composer's
|
|
145
|
+
* own viewport trails the cursor (KC1 §5) — derived per read, never
|
|
146
|
+
* stored, so it can never disagree with the selection it is meant to
|
|
147
|
+
* follow.
|
|
148
|
+
*/
|
|
149
|
+
export function atWindow(total, selected, visible = AT_VISIBLE) {
|
|
150
|
+
const count = Math.min(total, visible);
|
|
151
|
+
const first = Math.max(0, Math.min(selected - count + 1, total - count));
|
|
152
|
+
return { first, count };
|
|
153
|
+
}
|
|
154
|
+
/** The path split into the two columns the panel draws: the file's own
|
|
155
|
+
* name, and the directory that qualifies it. A path with no slash is
|
|
156
|
+
* all name and no directory. */
|
|
157
|
+
function splitPath(path) {
|
|
158
|
+
const cut = path.lastIndexOf("/");
|
|
159
|
+
return cut === -1 ? { dir: "", name: path } : { dir: path.slice(0, cut + 1), name: path.slice(cut + 1) };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* KC3 §4 — ONE row of the panel.
|
|
163
|
+
*
|
|
164
|
+
* Two columns: the file's NAME on the left with its matched characters
|
|
165
|
+
* bold, and the DIRECTORY dim on the right, pushed to the far edge.
|
|
166
|
+
* The name is what the user is aiming at; the directory is what tells
|
|
167
|
+
* two same-named files apart, which is why it is present but quiet.
|
|
168
|
+
*
|
|
169
|
+
* The selected row carries `→` on the inverse band (SGR 7, closed with
|
|
170
|
+
* 27 — never SGR 0, so it composes inside the row's own spans).
|
|
171
|
+
*
|
|
172
|
+
* The `hit` indices are over the FULL path, so they are shifted by the
|
|
173
|
+
* directory's length to land on the name. A hit that falls INSIDE the
|
|
174
|
+
* directory is simply not drawn bold — the directory column is
|
|
175
|
+
* uniformly dim by design (a bold fragment in a right-aligned dim
|
|
176
|
+
* column reads as damage, not as information).
|
|
177
|
+
*
|
|
178
|
+
* The row never exceeds W: the name cuts first (it is the flexible
|
|
179
|
+
* column), and the directory is dropped entirely before the name is
|
|
180
|
+
* cut to nothing.
|
|
181
|
+
*/
|
|
182
|
+
export function atRow(match, selected, W) {
|
|
183
|
+
const p = palette();
|
|
184
|
+
const { dir, name } = splitPath(escapeTerminal(match.path));
|
|
185
|
+
const lead = selected ? `${p.rv}→ ${p.rvEnd}` : " ";
|
|
186
|
+
// the name's own matched positions, mapped out of the full path
|
|
187
|
+
const marks = new Set(match.hit.filter((i) => i >= dir.length).map((i) => i - dir.length));
|
|
188
|
+
// room: W − the 2-cell lead − 1 separating space before the directory
|
|
189
|
+
const nameRoom = Math.max(1, W - 2 - (dir === "" ? 0 : visibleWidth(dir) + 1));
|
|
190
|
+
const shownName = widthCut(name, nameRoom);
|
|
191
|
+
let painted = "";
|
|
192
|
+
for (let i = 0; i < shownName.length; i += 1) {
|
|
193
|
+
painted += marks.has(i) ? `${p.bold}${shownName[i]}${p.reset}` : shownName[i];
|
|
194
|
+
}
|
|
195
|
+
if (dir === "")
|
|
196
|
+
return `${lead}${painted}`;
|
|
197
|
+
const pad = Math.max(1, W - 2 - visibleWidth(shownName) - visibleWidth(dir));
|
|
198
|
+
return `${lead}${painted}${" ".repeat(pad)}${p.dim}${dir}${p.reset}`;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* KC3 §4 — the counter row: `(n/total)`, where n is the 1-based
|
|
202
|
+
* position of the SELECTION in the whole ranked list, not in the
|
|
203
|
+
* visible window. The user needs to know where they are in the list,
|
|
204
|
+
* which the five visible rows cannot tell them.
|
|
205
|
+
*
|
|
206
|
+
* When the source list was truncated the row SAYS SO. A file picker
|
|
207
|
+
* that quietly lists 2,000 of 40,000 files and shows a confident
|
|
208
|
+
* "(3/1998)" is lying by omission; this one admits the horizon.
|
|
209
|
+
*/
|
|
210
|
+
export function atCounterRow(selected, total, capped, W) {
|
|
211
|
+
const p = palette();
|
|
212
|
+
const text = capped ? ` (${selected + 1}/${total}) · first ${AT_CAP} files only` : ` (${selected + 1}/${total})`;
|
|
213
|
+
return `${p.dim}${widthCut(text, W)}${p.reset}`;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* KC3 §4 — the whole band: at most AT_VISIBLE windowed rows, then the
|
|
217
|
+
* counter. Returned as plain strings for the menu-rows channel, which
|
|
218
|
+
* already accounts them in chromeRows — the picker needs no geometry
|
|
219
|
+
* of its own, which is the entire reason it rides that channel.
|
|
220
|
+
*/
|
|
221
|
+
export function atPanelRows(state, W) {
|
|
222
|
+
const { first, count } = atWindow(state.matches.length, state.selected);
|
|
223
|
+
const rows = [];
|
|
224
|
+
for (let i = first; i < first + count; i += 1)
|
|
225
|
+
rows.push(atRow(state.matches[i], i === state.selected, W));
|
|
226
|
+
rows.push(atCounterRow(state.selected, state.matches.length, state.capped, W));
|
|
227
|
+
return rows;
|
|
228
|
+
}
|
package/dist/compositor.d.ts
CHANGED
|
@@ -44,6 +44,13 @@
|
|
|
44
44
|
*/
|
|
45
45
|
import { type MenuItem } from "./editor.js";
|
|
46
46
|
import { type PanelState } from "./approval-panel.js";
|
|
47
|
+
import { type AtMatch } from "./at-picker.js";
|
|
48
|
+
/** KC3 §4 — the @ picker's bound state (the editor's atState()). */
|
|
49
|
+
export interface AtPanelState {
|
|
50
|
+
readonly matches: readonly AtMatch[];
|
|
51
|
+
readonly selected: number;
|
|
52
|
+
readonly capped: boolean;
|
|
53
|
+
}
|
|
47
54
|
import { type ResumeMeta } from "./render.js";
|
|
48
55
|
/** The cursor marker — an APC private sequence the focus component
|
|
49
56
|
* embeds at the edit position; the compositor strips it and moves
|
|
@@ -205,6 +212,10 @@ export declare class Body {
|
|
|
205
212
|
items: readonly MenuItem[];
|
|
206
213
|
selected: number;
|
|
207
214
|
} | null): void;
|
|
215
|
+
/** KC3 §4: bind the editor's @ file picker. It shares the menu's
|
|
216
|
+
* band — see #menuRows for why that is a decision and not a
|
|
217
|
+
* shortcut. */
|
|
218
|
+
bindAt(state: () => AtPanelState | null): void;
|
|
208
219
|
/** Bind the pending-turn queue — the CLI's live slots (chat.ts):
|
|
209
220
|
* the chips render in the menu-rows family, the live caps shrink
|
|
210
221
|
* by their rows, and the +N queued hint rides the status row. */
|
|
@@ -246,6 +257,10 @@ export declare class Dock {
|
|
|
246
257
|
items: readonly MenuItem[];
|
|
247
258
|
selected: number;
|
|
248
259
|
} | null): void;
|
|
260
|
+
/** KC3 §4: bind the editor's @ picker — the SAME band as the slash
|
|
261
|
+
* menu (see Body#menuRows). Unbound, the picker cannot render, and
|
|
262
|
+
* every frame is byte-identical to before the round. */
|
|
263
|
+
bindAt(state: () => AtPanelState | null): void;
|
|
249
264
|
/** W22: bind the pending-turn queue — the chips + the +N queued
|
|
250
265
|
* hint (the CLI binds it from chat(); the editor's pop keys ride
|
|
251
266
|
* the LineInput's own bindQueue). */
|
package/dist/compositor.js
CHANGED
|
@@ -46,6 +46,7 @@ import { truncateDiff } from "./diff.js";
|
|
|
46
46
|
import { displayWidth } from "./editor.js";
|
|
47
47
|
import { leadWidth } from "./width.js"; // W23: the ONE width authority (the editor, #inputRow, and editCol share it)
|
|
48
48
|
import { panelAffordance, panelBlockRows, panelLead, panelStatus } from "./approval-panel.js";
|
|
49
|
+
import { atPanelRows } from "./at-picker.js";
|
|
49
50
|
import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, foldLine, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.js";
|
|
50
51
|
import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
|
|
51
52
|
/** The cursor marker — an APC private sequence the focus component
|
|
@@ -118,6 +119,9 @@ export class Body {
|
|
|
118
119
|
#inputState = () => ({ line: "", cursor: 0 });
|
|
119
120
|
#inputPrompt = "";
|
|
120
121
|
#menuState = null;
|
|
122
|
+
// KC3 §4: the @ picker's bound state — the SAME band as the menu
|
|
123
|
+
// (see #menuRows: the two are mutually exclusive by construction).
|
|
124
|
+
#atState = null;
|
|
121
125
|
// W22: the pending-turn queue's bound state — the CLI's live slots
|
|
122
126
|
// (chat.ts); the chips render in the menu-rows family (above the
|
|
123
127
|
// box top), the live caps shrink by their rows, and the status
|
|
@@ -144,6 +148,8 @@ export class Body {
|
|
|
144
148
|
this.#inputPrompt = dockBindings.prompt;
|
|
145
149
|
if (dockBindings.menu !== null)
|
|
146
150
|
this.#menuState = dockBindings.menu;
|
|
151
|
+
if (dockBindings.at !== null)
|
|
152
|
+
this.#atState = dockBindings.at;
|
|
147
153
|
this.#panelState = dockBindings.panel;
|
|
148
154
|
if (dockBindings.queue !== null)
|
|
149
155
|
this.#queueState = dockBindings.queue;
|
|
@@ -675,6 +681,12 @@ export class Body {
|
|
|
675
681
|
bindMenu(state) {
|
|
676
682
|
this.#menuState = state;
|
|
677
683
|
}
|
|
684
|
+
/** KC3 §4: bind the editor's @ file picker. It shares the menu's
|
|
685
|
+
* band — see #menuRows for why that is a decision and not a
|
|
686
|
+
* shortcut. */
|
|
687
|
+
bindAt(state) {
|
|
688
|
+
this.#atState = state;
|
|
689
|
+
}
|
|
678
690
|
/** Bind the pending-turn queue — the CLI's live slots (chat.ts):
|
|
679
691
|
* the chips render in the menu-rows family, the live caps shrink
|
|
680
692
|
* by their rows, and the +N queued hint rides the status row. */
|
|
@@ -1098,7 +1110,24 @@ export class Body {
|
|
|
1098
1110
|
const hidden = lines.length - keep;
|
|
1099
1111
|
return [...pendingQueueRows(lines.slice(0, keep), W), `${p.dim}□ …${hidden} more queued${p.reset}`];
|
|
1100
1112
|
}
|
|
1113
|
+
/**
|
|
1114
|
+
* The band ABOVE the box top. Two occupants share it — the slash
|
|
1115
|
+
* menu and (KC3 §4) the @ file picker.
|
|
1116
|
+
*
|
|
1117
|
+
* Sharing is the design, not an economy. This band is already
|
|
1118
|
+
* counted in chromeRows, already shrinks the live content cap,
|
|
1119
|
+
* already redraws with the frame, and already clamps the composer's
|
|
1120
|
+
* visible rows; a picker with a band of its own would have to
|
|
1121
|
+
* re-derive every one of those and could disagree with any of them.
|
|
1122
|
+
* The two occupants are mutually exclusive BY CONSTRUCTION — the
|
|
1123
|
+
* editor's precedence gate keeps the picker shut whenever the menu
|
|
1124
|
+
* is open — so one function can own the band without either
|
|
1125
|
+
* occupant knowing the other exists.
|
|
1126
|
+
*/
|
|
1101
1127
|
#menuRows(W) {
|
|
1128
|
+
const at = this.#atState?.() ?? null;
|
|
1129
|
+
if (at !== null)
|
|
1130
|
+
return atPanelRows(at, W);
|
|
1102
1131
|
const menu = this.#menuState?.();
|
|
1103
1132
|
if (menu === null || menu === undefined || menu.items.length === 0)
|
|
1104
1133
|
return [];
|
|
@@ -1589,6 +1618,16 @@ export class Dock {
|
|
|
1589
1618
|
}
|
|
1590
1619
|
compositorRef.bindMenu(state);
|
|
1591
1620
|
}
|
|
1621
|
+
/** KC3 §4: bind the editor's @ picker — the SAME band as the slash
|
|
1622
|
+
* menu (see Body#menuRows). Unbound, the picker cannot render, and
|
|
1623
|
+
* every frame is byte-identical to before the round. */
|
|
1624
|
+
bindAt(state) {
|
|
1625
|
+
if (compositorRef === null) {
|
|
1626
|
+
dockBindings.at = state;
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
compositorRef.bindAt(state);
|
|
1630
|
+
}
|
|
1592
1631
|
/** W22: bind the pending-turn queue — the chips + the +N queued
|
|
1593
1632
|
* hint (the CLI binds it from chat(); the editor's pop keys ride
|
|
1594
1633
|
* the LineInput's own bindQueue). */
|
|
@@ -1614,4 +1653,4 @@ let compositorRef = null;
|
|
|
1614
1653
|
* — the old snapshot froze `menu` at bindInput time and the slash-
|
|
1615
1654
|
* command menu silently never bound in the real CLI (the e2e gates
|
|
1616
1655
|
* bind the Body directly and could not see it). */
|
|
1617
|
-
const dockBindings = { state: null, prompt: "", menu: null, panel: null, queue: null };
|
|
1656
|
+
const dockBindings = { state: null, prompt: "", menu: null, at: null, panel: null, queue: null };
|
package/dist/editor.d.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { charWidth, displayWidth, widthOf } from "./width.js";
|
|
25
25
|
export { charWidth, displayWidth, widthOf };
|
|
26
26
|
import { type PanelState, type PanelVerdict, type PanelView } from "./approval-panel.js";
|
|
27
|
+
import { type AtItem, type AtMatch } from "./at-picker.js";
|
|
27
28
|
export declare const PROMPT = "\u258C ";
|
|
28
29
|
export declare const PROMPT_WIDTH: number;
|
|
29
30
|
/** v3 §04 — the slash-command menu's command table (English one-liners). */
|
|
@@ -48,6 +49,11 @@ export declare class Editor {
|
|
|
48
49
|
onEot(cb: () => void): void;
|
|
49
50
|
onEscape(cb: () => void): void;
|
|
50
51
|
onExpand(cb: () => void): void;
|
|
52
|
+
/** KC2 §2: the redirect chain — the gesture hands the buffer's text
|
|
53
|
+
* over while the run is told to stop. Mirrors onEscape (a list, so
|
|
54
|
+
* listeners can coexist); the line arrives already gone from the
|
|
55
|
+
* composer, exactly as a submit's does. */
|
|
56
|
+
onRedirect(cb: (line: string) => void): void;
|
|
51
57
|
/** W22: bind the pending-turn queue — the CLI's live slots. The ↑
|
|
52
58
|
* pop walks them (each pop leaves the queue, cancelling the turn);
|
|
53
59
|
* esc ends the walk after one more pop. */
|
|
@@ -78,6 +84,20 @@ export declare class Editor {
|
|
|
78
84
|
items: readonly MenuItem[];
|
|
79
85
|
selected: number;
|
|
80
86
|
} | null;
|
|
87
|
+
/** KC3 §3 — bind the file source. The tui owns no file list and
|
|
88
|
+
* never touches a disk (input is data, output is bytes): the CLI
|
|
89
|
+
* feeds the paths, and until it does, the picker cannot open at
|
|
90
|
+
* all — which is exactly why every non-@ scenario and every
|
|
91
|
+
* consumer that does not bind (the recovery flow, the existing
|
|
92
|
+
* gates) is byte-identical. */
|
|
93
|
+
bindAtItems(source: () => readonly AtItem[]): void;
|
|
94
|
+
/** KC3 §4 — the picker's visible state for the dock; null when
|
|
95
|
+
* closed. The compositor windows it and draws the counter. */
|
|
96
|
+
atState(): {
|
|
97
|
+
matches: readonly AtMatch[];
|
|
98
|
+
selected: number;
|
|
99
|
+
capped: boolean;
|
|
100
|
+
} | null;
|
|
81
101
|
/** One-shot question mode: the NEXT submit answers, not a turn. */
|
|
82
102
|
question(_query: string, cb: (answer: string) => void): void;
|
|
83
103
|
/** Cancel a pending question — the buffer stays (its text becomes the
|
package/dist/editor.js
CHANGED
|
@@ -27,6 +27,7 @@ import { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
|
|
|
27
27
|
export { charWidth, displayWidth, widthOf };
|
|
28
28
|
import { palette } from "./render.js";
|
|
29
29
|
import { panelLead } from "./approval-panel.js";
|
|
30
|
+
import { AT_VISIBLE, atFilter } from "./at-picker.js";
|
|
30
31
|
// TUI v4 #16d: the input row is the blue brick + the edit area — the
|
|
31
32
|
// "you>" text is gone (the brick IS the prompt; the pipe path's readline
|
|
32
33
|
// prompt keeps its own "you> " — v2a line mode, byte-for-byte).
|
|
@@ -44,6 +45,13 @@ export const MENU_ITEMS = [
|
|
|
44
45
|
/** KC1 §3 — the newline code point. Every source (paste, Ctrl+J, the
|
|
45
46
|
* Shift+Enter encodings, a CRLF pair) normalizes to exactly ONE. */
|
|
46
47
|
const NEWLINE = 0x0a;
|
|
48
|
+
/** KC3 §3 — the picker's sigil, and the two characters that count as a
|
|
49
|
+
* word boundary before it. A `@` anywhere else (vince@example.com) is
|
|
50
|
+
* an ordinary character: the reference is a thing you START, not a
|
|
51
|
+
* thing an address accidentally becomes. */
|
|
52
|
+
const AT = 0x40;
|
|
53
|
+
const SPACE = 0x20;
|
|
54
|
+
const TAB = 0x09;
|
|
47
55
|
/** KC1 §5 — the composer's CEILING (adjudication A1): at most 6 visible
|
|
48
56
|
* rows. A ceiling only — N_visible clamps by the terminal's height so
|
|
49
57
|
* the geometry stays legal down to the compositor's enter gate (H = 4
|
|
@@ -87,6 +95,12 @@ export class Editor {
|
|
|
87
95
|
// (dispatch) coexist; a listener removes itself via an unarmed guard
|
|
88
96
|
// (the compact's handler no-ops after its abort has fired).
|
|
89
97
|
#escapeCbs = [];
|
|
98
|
+
// KC2 §2: the redirect LIST — mirrors #escapeCbs. The editor FORWARDS
|
|
99
|
+
// the gesture with the buffer's text; it never interprets it. What a
|
|
100
|
+
// redirect MEANS (abort the run, then run THIS ahead of the queue) is
|
|
101
|
+
// the CLI's — here it is only "these two keys, pressed together, hand
|
|
102
|
+
// the line over by a different door than Enter's".
|
|
103
|
+
#redirectCbs = [];
|
|
90
104
|
// W15: the expand-key list (ctrl+r) — the CLI's dispatch decides the
|
|
91
105
|
// target (a live cell toggles in place; a committed cell appends the
|
|
92
106
|
// expanded block). Mirrors the escape list: multiple listeners can
|
|
@@ -95,6 +109,22 @@ export class Editor {
|
|
|
95
109
|
#onRender;
|
|
96
110
|
#menuOpen = false; // v3 §04: the slash-command menu
|
|
97
111
|
#menuSel = 0;
|
|
112
|
+
// KC3 §3 — the @ file picker. THREE fields and no more: the armed
|
|
113
|
+
// bit, the selection, and the per-open SNAPSHOT of the file list.
|
|
114
|
+
// The query is deliberately NOT stored — it is derived from the
|
|
115
|
+
// buffer and the cursor on every read (the KC1 flat-buffer
|
|
116
|
+
// discipline: never a second mutable model). That is what makes
|
|
117
|
+
// backspacing past the `@` close the picker with no handler
|
|
118
|
+
// anywhere, and what keeps every existing op — the kills, paste,
|
|
119
|
+
// the history stash, the queue-pop replace — correct for free.
|
|
120
|
+
#atOpen = false;
|
|
121
|
+
#atSel = 0;
|
|
122
|
+
// the list is snapshotted AT OPEN and held for that open's lifetime
|
|
123
|
+
// (§4: no index, no watcher, no re-listing per keystroke). An armed
|
|
124
|
+
// bit with no token under the cursor is inert by construction — the
|
|
125
|
+
// next open re-snapshots, so a stale list can never be shown.
|
|
126
|
+
#atList = null;
|
|
127
|
+
#atItems = null;
|
|
98
128
|
// A2 (the feel): the session-scoped input history — every submitted TURN
|
|
99
129
|
// line (never a question answer), capped at 100, never persisted. ↑↓
|
|
100
130
|
// navigate it ONLY from an empty input or while already browsing.
|
|
@@ -144,6 +174,13 @@ export class Editor {
|
|
|
144
174
|
onExpand(cb) {
|
|
145
175
|
this.#expandCbs.push(cb);
|
|
146
176
|
}
|
|
177
|
+
/** KC2 §2: the redirect chain — the gesture hands the buffer's text
|
|
178
|
+
* over while the run is told to stop. Mirrors onEscape (a list, so
|
|
179
|
+
* listeners can coexist); the line arrives already gone from the
|
|
180
|
+
* composer, exactly as a submit's does. */
|
|
181
|
+
onRedirect(cb) {
|
|
182
|
+
this.#redirectCbs.push(cb);
|
|
183
|
+
}
|
|
147
184
|
/** W22: bind the pending-turn queue — the CLI's live slots. The ↑
|
|
148
185
|
* pop walks them (each pop leaves the queue, cancelling the turn);
|
|
149
186
|
* esc ends the walk after one more pop. */
|
|
@@ -202,7 +239,7 @@ export class Editor {
|
|
|
202
239
|
* the frame's clamp is the authority. */
|
|
203
240
|
#visibleRows(lineCount) {
|
|
204
241
|
const H = process.stdout.rows ?? 24;
|
|
205
|
-
const bands = (this.#menuOpen ? this.#menuFiltered().length : 0) + this.#queueState().length;
|
|
242
|
+
const bands = (this.#menuOpen ? this.#menuFiltered().length : 0) + this.#atRows() + this.#queueState().length;
|
|
206
243
|
return Math.max(1, Math.min(lineCount, N_MAX, Math.max(1, H - 3 - bands)));
|
|
207
244
|
}
|
|
208
245
|
/** The dock's input-row state — ADDITIVE (§5): `line` + `cursor` keep
|
|
@@ -263,6 +300,128 @@ export class Editor {
|
|
|
263
300
|
this.#menuSel = 0;
|
|
264
301
|
this.#onRender();
|
|
265
302
|
}
|
|
303
|
+
/** KC3 §3 — bind the file source. The tui owns no file list and
|
|
304
|
+
* never touches a disk (input is data, output is bytes): the CLI
|
|
305
|
+
* feeds the paths, and until it does, the picker cannot open at
|
|
306
|
+
* all — which is exactly why every non-@ scenario and every
|
|
307
|
+
* consumer that does not bind (the recovery flow, the existing
|
|
308
|
+
* gates) is byte-identical. */
|
|
309
|
+
bindAtItems(source) {
|
|
310
|
+
this.#atItems = source;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* KC3 §3 — the token under the cursor, DERIVED. Scans back from the
|
|
314
|
+
* cursor within the CURSOR'S LINE for the `@` that opens it:
|
|
315
|
+
* - whitespace before finding one → there is no token (the space
|
|
316
|
+
* ended it);
|
|
317
|
+
* - an `@` that is not itself at a word boundary → inert (the
|
|
318
|
+
* email case: the `@` of vince@example.com opens nothing);
|
|
319
|
+
* - otherwise the token runs from that `@` to the CURSOR — never
|
|
320
|
+
* to the end of the line, so `@ra|.js` narrows on "ra".
|
|
321
|
+
* Line-local: the start of any line of a multi-line composer is a
|
|
322
|
+
* boundary, exactly like the start of the buffer.
|
|
323
|
+
*/
|
|
324
|
+
#atToken() {
|
|
325
|
+
const b = this.#cursorBounds();
|
|
326
|
+
for (let i = this.#cursor - 1; i >= b.start; i -= 1) {
|
|
327
|
+
const cp = this.#chars[i];
|
|
328
|
+
if (cp === SPACE || cp === TAB)
|
|
329
|
+
return null;
|
|
330
|
+
if (cp !== AT)
|
|
331
|
+
continue;
|
|
332
|
+
const before = i > b.start ? this.#chars[i - 1] : null;
|
|
333
|
+
if (before !== null && before !== SPACE && before !== TAB)
|
|
334
|
+
return null; // mid-word
|
|
335
|
+
return { start: i, query: String.fromCodePoint(...this.#chars.slice(i + 1, this.#cursor)) };
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
/** KC3 §3 — the picker's full state, or null when it is not up. Up
|
|
340
|
+
* requires ALL of: armed, nobody with higher precedence holding the
|
|
341
|
+
* keys, a live token under the cursor, and at least one match (the
|
|
342
|
+
* menu's precedent — a panel with nothing in it is noise, and the
|
|
343
|
+
* keys fall back to their ordinary meanings). */
|
|
344
|
+
#atView() {
|
|
345
|
+
if (!this.#atOpen || this.#atList === null)
|
|
346
|
+
return null;
|
|
347
|
+
if (this.#panel !== null || this.#menuOpen)
|
|
348
|
+
return null;
|
|
349
|
+
const token = this.#atToken();
|
|
350
|
+
if (token === null)
|
|
351
|
+
return null;
|
|
352
|
+
const { matches, capped } = atFilter(this.#atList, token.query);
|
|
353
|
+
if (matches.length === 0)
|
|
354
|
+
return null;
|
|
355
|
+
// the selection CLAMPS at read time rather than being corrected
|
|
356
|
+
// on every edit — narrowing the query can only ever shrink the
|
|
357
|
+
// list, and a clamp is the whole correction that needs
|
|
358
|
+
return { matches, selected: Math.min(this.#atSel, matches.length - 1), capped, start: token.start };
|
|
359
|
+
}
|
|
360
|
+
#atUp() {
|
|
361
|
+
return this.#atView() !== null;
|
|
362
|
+
}
|
|
363
|
+
/** KC3 §4 — the picker's visible state for the dock; null when
|
|
364
|
+
* closed. The compositor windows it and draws the counter. */
|
|
365
|
+
atState() {
|
|
366
|
+
const view = this.#atView();
|
|
367
|
+
if (view === null)
|
|
368
|
+
return null;
|
|
369
|
+
return { matches: view.matches, selected: view.selected, capped: view.capped };
|
|
370
|
+
}
|
|
371
|
+
/** KC3 §3 — arm the picker at a freshly typed `@`. The gate is the
|
|
372
|
+
* KC2 precedence pattern: the approval panel, the slash menu and a
|
|
373
|
+
* pending question each own the keys first. A paste is literal text
|
|
374
|
+
* (guarded by the caller). The history browse and the queue-pop
|
|
375
|
+
* walk are NOT re-tested here because typing has already ended them
|
|
376
|
+
* — #insert leaves both before a character ever lands. */
|
|
377
|
+
#atArm() {
|
|
378
|
+
if (this.#atItems === null)
|
|
379
|
+
return;
|
|
380
|
+
if (this.#panel !== null || this.#menuOpen || this.#questionCb !== null)
|
|
381
|
+
return;
|
|
382
|
+
if (this.#atToken() === null)
|
|
383
|
+
return; // not at a word boundary
|
|
384
|
+
this.#atOpen = true;
|
|
385
|
+
this.#atSel = 0;
|
|
386
|
+
this.#atList = this.#atItems(); // §5: listed per OPEN, never per keystroke
|
|
387
|
+
}
|
|
388
|
+
#atClose() {
|
|
389
|
+
this.#atOpen = false;
|
|
390
|
+
this.#atSel = 0;
|
|
391
|
+
this.#atList = null;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* KC3 §3 — accept: the token becomes `@<path> `.
|
|
395
|
+
*
|
|
396
|
+
* The CANONICAL PATH and a trailing space, and nothing else — the
|
|
397
|
+
* file's CONTENT is never inserted. That is the whole product
|
|
398
|
+
* decision: the model is handed a reference it can choose to read,
|
|
399
|
+
* so an @ mention costs a path's worth of tokens instead of a
|
|
400
|
+
* file's, and the model's own read_file call is what pays for the
|
|
401
|
+
* bytes it actually needs.
|
|
402
|
+
*
|
|
403
|
+
* Only [token.start, cursor) is replaced, so text after the cursor
|
|
404
|
+
* survives and a multi-line buffer keeps every other line.
|
|
405
|
+
*/
|
|
406
|
+
#atAccept() {
|
|
407
|
+
const view = this.#atView();
|
|
408
|
+
if (view === null)
|
|
409
|
+
return;
|
|
410
|
+
const insert = [...`@${view.matches[view.selected].path} `].map((ch) => ch.codePointAt(0));
|
|
411
|
+
this.#chars.splice(view.start, this.#cursor - view.start, ...insert);
|
|
412
|
+
this.#cursor = view.start + insert.length;
|
|
413
|
+
this.#atClose();
|
|
414
|
+
this.#reflow();
|
|
415
|
+
this.#onRender();
|
|
416
|
+
}
|
|
417
|
+
/** KC3 §4 — the picker's band height, the editor's honest estimate
|
|
418
|
+
* (the compositor re-applies the clamp against the frame's REAL
|
|
419
|
+
* folded rows, exactly as it does for the menu): the windowed rows
|
|
420
|
+
* plus the counter row. */
|
|
421
|
+
#atRows() {
|
|
422
|
+
const view = this.#atView();
|
|
423
|
+
return view === null ? 0 : Math.min(view.matches.length, AT_VISIBLE) + 1;
|
|
424
|
+
}
|
|
266
425
|
/** One-shot question mode: the NEXT submit answers, not a turn. */
|
|
267
426
|
question(_query, cb) {
|
|
268
427
|
this.#questionCb = cb;
|
|
@@ -291,6 +450,7 @@ export class Editor {
|
|
|
291
450
|
this.#menuOpen = false;
|
|
292
451
|
this.#menuSel = 0;
|
|
293
452
|
this.#queuePopMode = false; // W22: the panel owns the keys while up
|
|
453
|
+
this.#atClose(); // KC3 §3: and the picker closes with everything else
|
|
294
454
|
this.#onRender();
|
|
295
455
|
}
|
|
296
456
|
/** W21: cancel the panel — the SIGINT path's pair to beginPanel. */
|
|
@@ -408,6 +568,17 @@ export class Editor {
|
|
|
408
568
|
else if (rest.startsWith("O")) {
|
|
409
569
|
i += 3; // SS3 (function keys) — ignored
|
|
410
570
|
}
|
|
571
|
+
else if (rest.startsWith("\x0d") && this.#composerIdle()) {
|
|
572
|
+
// KC2 §2 — Alt+Enter. A terminal sends Alt+X as ESC and X in
|
|
573
|
+
// ONE write, so SAME-CHUNK is the whole test: no timer, no
|
|
574
|
+
// hold, nothing parked. The identical two bytes arriving in
|
|
575
|
+
// SEPARATE chunks are NOT combined — they fall to the branch
|
|
576
|
+
// below, where the bare Esc fires at once (its immediacy is
|
|
577
|
+
// exactly what a hold would spend) and the next chunk's CR
|
|
578
|
+
// submits: today's two gestures, untouched.
|
|
579
|
+
this.#redirect();
|
|
580
|
+
i += 2; // both bytes belong to the one gesture
|
|
581
|
+
}
|
|
411
582
|
else if (this.#menuOpen) {
|
|
412
583
|
// v3 §04: Esc closes the menu and clears the buffer.
|
|
413
584
|
// CA-4: the closing esc consumes its burst (the `i += 1`
|
|
@@ -419,6 +590,17 @@ export class Editor {
|
|
|
419
590
|
this.#refreshMenu();
|
|
420
591
|
i += 1;
|
|
421
592
|
}
|
|
593
|
+
else if (this.#atUp()) {
|
|
594
|
+
// KC3 §3: esc closes the picker and leaves the BUFFER
|
|
595
|
+
// ALONE — unlike the menu's esc, which clears it. The
|
|
596
|
+
// sentence around the reference is still being written,
|
|
597
|
+
// and a dismissed picker must not take it away. CA-4:
|
|
598
|
+
// the closing esc consumes its burst, so it can never
|
|
599
|
+
// also abort the run.
|
|
600
|
+
this.#atClose();
|
|
601
|
+
this.#onRender();
|
|
602
|
+
i += 1;
|
|
603
|
+
}
|
|
422
604
|
else if (this.#queuePopMode) {
|
|
423
605
|
// W22: esc in the pop-mode — ONE more pop, then the
|
|
424
606
|
// mode ends: the next esc at rest rides the escapeCbs
|
|
@@ -515,6 +697,12 @@ export class Editor {
|
|
|
515
697
|
}
|
|
516
698
|
i += 1;
|
|
517
699
|
}
|
|
700
|
+
else if (c === "\t" && this.#atUp()) {
|
|
701
|
+
// KC3 §3: Tab accepts the selected path — the token becomes
|
|
702
|
+
// `@<path> `. Never the file's content.
|
|
703
|
+
this.#atAccept();
|
|
704
|
+
i += 1;
|
|
705
|
+
}
|
|
518
706
|
else if (c === "\x12") {
|
|
519
707
|
// W15: the expand key (ctrl+r) — rides the chain like a
|
|
520
708
|
// command, the editor just forwards it.
|
|
@@ -541,6 +729,18 @@ export class Editor {
|
|
|
541
729
|
this.#insert(NEWLINE);
|
|
542
730
|
return;
|
|
543
731
|
}
|
|
732
|
+
// KC2 §2 — Ctrl+Enter, the SAME two encodings with modifier 5
|
|
733
|
+
// (1 + ctrl): kitty's CSI-u and xterm's modifyOtherKeys. Never
|
|
734
|
+
// claimed universal — a terminal that encodes neither sends a plain
|
|
735
|
+
// CR, which is an ordinary submit/queue (the safe degrade). The
|
|
736
|
+
// chunk-split safety is the existing #pending CSI resume, shared
|
|
737
|
+
// with Shift+Enter above. Outside the normal composer state the
|
|
738
|
+
// sequence is simply unknown, exactly like any other stray CSI.
|
|
739
|
+
if ((final === "u" && params === "13;5") || (final === "~" && params === "27;5;13")) {
|
|
740
|
+
if (this.#composerIdle())
|
|
741
|
+
this.#redirect();
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
544
744
|
if (final === "~") {
|
|
545
745
|
const n = Number(params);
|
|
546
746
|
if (n === 3)
|
|
@@ -568,6 +768,14 @@ export class Editor {
|
|
|
568
768
|
else
|
|
569
769
|
this.#menuSel = Math.min(this.#menuFiltered().length - 1, this.#menuSel + 1);
|
|
570
770
|
}
|
|
771
|
+
else if (this.#atUp()) {
|
|
772
|
+
// KC3 §3: the picker owns ↑↓ while up — the SELECTION, never
|
|
773
|
+
// the cursor and never the composer's line walk. It sits
|
|
774
|
+
// ABOVE the multi-line branch on purpose: a picker opened on
|
|
775
|
+
// line 2 of a composer must still select.
|
|
776
|
+
const view = this.#atView();
|
|
777
|
+
this.#atSel = final === "A" ? Math.max(0, view.selected - 1) : Math.min(view.matches.length - 1, view.selected + 1);
|
|
778
|
+
}
|
|
571
779
|
else if (this.#chars.includes(NEWLINE)) {
|
|
572
780
|
// KC1 §4: a MULTI-LINE buffer's ↑↓ walk its lines. The
|
|
573
781
|
// history and the queue-pop below stay gated on an EMPTY
|
|
@@ -728,6 +936,11 @@ export class Editor {
|
|
|
728
936
|
this.#reflow();
|
|
729
937
|
if (!this.#pasting)
|
|
730
938
|
this.#refreshMenu();
|
|
939
|
+
// KC3 §3: a TYPED `@` arms the picker; a PASTED one never does —
|
|
940
|
+
// a paste is content, and content that happens to contain an
|
|
941
|
+
// address must not open a file browser mid-sentence.
|
|
942
|
+
if (cp === AT && !this.#pasting)
|
|
943
|
+
this.#atArm();
|
|
731
944
|
}
|
|
732
945
|
#backspace() {
|
|
733
946
|
if (this.#cursor === 0)
|
|
@@ -783,8 +996,86 @@ export class Editor {
|
|
|
783
996
|
this.#cursor = i;
|
|
784
997
|
this.#reflow();
|
|
785
998
|
}
|
|
999
|
+
/** KC1/KC2 — the buffer LEAVES: the flat chars, the cursor, the
|
|
1000
|
+
* horizontal scroll, the ↑/↓ goal, the menu and the pop-walk all
|
|
1001
|
+
* reset together (W22: a departing line ends the pop-walk, so the
|
|
1002
|
+
* next esc at rest interrupts again). Shared by the submit and the
|
|
1003
|
+
* redirect — the two doors a line can leave by. */
|
|
1004
|
+
#takeLine() {
|
|
1005
|
+
const line = String.fromCodePoint(...this.#chars);
|
|
1006
|
+
this.#chars = [];
|
|
1007
|
+
this.#cursor = 0;
|
|
1008
|
+
this.#scroll = 0;
|
|
1009
|
+
this.#verticalGoalCol = null;
|
|
1010
|
+
this.#menuOpen = false;
|
|
1011
|
+
this.#menuSel = 0;
|
|
1012
|
+
this.#queuePopMode = false;
|
|
1013
|
+
this.#atClose(); // KC3 §3: a departing line takes its picker with it
|
|
1014
|
+
return line;
|
|
1015
|
+
}
|
|
1016
|
+
/** A2: the history remembers submitted TURN lines — never question
|
|
1017
|
+
* answers, never empties; adjacent duplicates collapse, the tail
|
|
1018
|
+
* caps at 100. A redirect is a turn, so it is remembered too. */
|
|
1019
|
+
#remember(line) {
|
|
1020
|
+
if (this.#history[this.#history.length - 1] !== line)
|
|
1021
|
+
this.#history.push(line);
|
|
1022
|
+
if (this.#history.length > 100)
|
|
1023
|
+
this.#history.shift();
|
|
1024
|
+
}
|
|
1025
|
+
/** KC2 §2 — the NORMAL composer state: the redirect gesture is live
|
|
1026
|
+
* ONLY here. The approval panel, the slash menu, the history browse
|
|
1027
|
+
* and the queue-pop walk each OWN their keys first (the W21 "the
|
|
1028
|
+
* panel owns the keys" design, restated as a gate); a pending
|
|
1029
|
+
* question is the panel's dock-less twin (askPanel routes to
|
|
1030
|
+
* question() when the dock cannot render, so the ask owns the keys
|
|
1031
|
+
* there too); and a bracketed paste is literal TEXT, where an ESC CR
|
|
1032
|
+
* is the pasted content's own bytes and never a keypress. In every
|
|
1033
|
+
* one of those states the two bytes fall through to today's
|
|
1034
|
+
* handling — two gestures, unchanged. */
|
|
1035
|
+
#composerIdle() {
|
|
1036
|
+
return (this.#panel === null &&
|
|
1037
|
+
!this.#menuOpen &&
|
|
1038
|
+
!this.#atUp() && // KC3 §3: the @ picker owns the keys while up, exactly like the menu
|
|
1039
|
+
this.#historyIdx === null &&
|
|
1040
|
+
!this.#queuePopMode &&
|
|
1041
|
+
!this.#pasting &&
|
|
1042
|
+
this.#questionCb === null);
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* KC2 §2 — the gesture's meaning, kept as small as it can honestly be.
|
|
1046
|
+
*
|
|
1047
|
+
* An EMPTY buffer carries no correction, so the gesture degenerates to
|
|
1048
|
+
* the bare Esc: the abort alone, nothing submitted. With text, the
|
|
1049
|
+
* line leaves exactly as a submit's does and the listeners decide (the
|
|
1050
|
+
* CLI aborts a live run and front-jumps the correction; idle, it is
|
|
1051
|
+
* simply an Enter). UNWIRED — the recovery flow never binds it — the
|
|
1052
|
+
* gesture IS a submit: a line is never lost to a missing binding.
|
|
1053
|
+
*/
|
|
1054
|
+
#redirect() {
|
|
1055
|
+
if (this.#chars.length === 0) {
|
|
1056
|
+
for (const cb of [...this.#escapeCbs])
|
|
1057
|
+
cb();
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (this.#redirectCbs.length === 0) {
|
|
1061
|
+
this.#submit();
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const line = this.#takeLine();
|
|
1065
|
+
this.#remember(line);
|
|
1066
|
+
for (const cb of [...this.#redirectCbs])
|
|
1067
|
+
cb(line);
|
|
1068
|
+
this.#onRender();
|
|
1069
|
+
}
|
|
786
1070
|
#submit() {
|
|
787
|
-
|
|
1071
|
+
// KC3 §3: Enter ACCEPTS while the picker is up — the same rule the
|
|
1072
|
+
// menu's A1 feel established (complete first, let the user read
|
|
1073
|
+
// what they got, and let the NEXT Enter send it). An @ reference
|
|
1074
|
+
// that submitted on the first Enter would send the fragment.
|
|
1075
|
+
if (this.#atUp()) {
|
|
1076
|
+
this.#atAccept();
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
788
1079
|
if (this.#menuOpen) {
|
|
789
1080
|
// A1 (the feel): Enter submits the EXACT selection directly; a
|
|
790
1081
|
// PARTIAL selection COMPLETES the buffer (the Tab semantics)
|
|
@@ -792,7 +1083,7 @@ export class Editor {
|
|
|
792
1083
|
// again. The old behavior executed the completed command on
|
|
793
1084
|
// the first Enter, before the user had seen the completion.
|
|
794
1085
|
const m = this.#menuFiltered()[this.#menuSel];
|
|
795
|
-
if (m !== undefined && m.name !== line) {
|
|
1086
|
+
if (m !== undefined && m.name !== this.line()) {
|
|
796
1087
|
this.#chars = [...m.name].map((ch) => ch.codePointAt(0));
|
|
797
1088
|
this.#cursor = this.#chars.length;
|
|
798
1089
|
this.#reflow();
|
|
@@ -801,13 +1092,7 @@ export class Editor {
|
|
|
801
1092
|
return; // completed, not executed
|
|
802
1093
|
}
|
|
803
1094
|
}
|
|
804
|
-
|
|
805
|
-
this.#cursor = 0;
|
|
806
|
-
this.#scroll = 0;
|
|
807
|
-
this.#verticalGoalCol = null;
|
|
808
|
-
this.#menuOpen = false;
|
|
809
|
-
this.#menuSel = 0;
|
|
810
|
-
this.#queuePopMode = false; // W22: a submit ends the pop-walk — the next esc at rest interrupts again
|
|
1095
|
+
const line = this.#takeLine();
|
|
811
1096
|
const cb = this.#questionCb;
|
|
812
1097
|
this.#questionCb = null;
|
|
813
1098
|
if (cb !== null) {
|
|
@@ -819,14 +1104,8 @@ export class Editor {
|
|
|
819
1104
|
else {
|
|
820
1105
|
this.#pendingLines.push(line); // nobody wired yet — hold it
|
|
821
1106
|
}
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
if (cb === null && line !== "") {
|
|
825
|
-
if (this.#history[this.#history.length - 1] !== line)
|
|
826
|
-
this.#history.push(line);
|
|
827
|
-
if (this.#history.length > 100)
|
|
828
|
-
this.#history.shift();
|
|
829
|
-
}
|
|
1107
|
+
if (cb === null && line !== "")
|
|
1108
|
+
this.#remember(line);
|
|
830
1109
|
this.#onRender();
|
|
831
1110
|
}
|
|
832
1111
|
/** A2: step the history browse; a delta past the newest exits back to
|
package/dist/index.d.ts
CHANGED
|
@@ -11,3 +11,6 @@ export { Container, foldLine, visibleWidth, SPINNER, type Component, type FrameC
|
|
|
11
11
|
export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, type MenuItem, } from "./editor.js";
|
|
12
12
|
export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type PathResolver, type RecapStats, type ResumeMeta, type RenderInput, type RenderResult, type RunUsage, } from "./render.js";
|
|
13
13
|
export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
|
|
14
|
+
export { STATUS_GLYPHS, idleStatus, runningStatus } from "./status.js";
|
|
15
|
+
export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, type TrustArtifact } from "./strings.js";
|
|
16
|
+
export { AT_CAP, AT_SKIP, AT_VISIBLE, atEmbed, atFilter, atPanelRows, atWindow, longestRun, type AtItem, type AtMatch } from "./at-picker.js";
|
package/dist/index.js
CHANGED
|
@@ -14,3 +14,13 @@ export { Container, foldLine, visibleWidth, SPINNER } from "./components.js";
|
|
|
14
14
|
export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, } from "./editor.js";
|
|
15
15
|
export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
|
|
16
16
|
export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
|
|
17
|
+
// KC2 §5: the status rows' formatters — the CLI keeps the state and the
|
|
18
|
+
// repaint, the terminal layer owns what the row says.
|
|
19
|
+
export { STATUS_GLYPHS, idleStatus, runningStatus } from "./status.js";
|
|
20
|
+
// KC3 §1 (the extraction): the human-facing strings — the prompt, the
|
|
21
|
+
// project-trust listing/view/note, the uncertain execution's view. The
|
|
22
|
+
// FLOW (who is asked, what a verdict means) stays in the cli.
|
|
23
|
+
export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView } from "./strings.js";
|
|
24
|
+
// KC3 §3/§5: the @ file picker's pure half — the subsequence filter, the
|
|
25
|
+
// deterministic rank, and the ONE cap the CLI's file source shares.
|
|
26
|
+
export { AT_CAP, AT_SKIP, AT_VISIBLE, atEmbed, atFilter, atPanelRows, atWindow, longestRun } from "./at-picker.js";
|
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KC2 §5 — the status line's FORMATTERS, extracted from the CLI (the
|
|
3
|
+
* ADR-0041 escape hatch: extraction, never a fifth raise). The split is
|
|
4
|
+
* the one the ADR names: the CLI keeps the STATE (the rotating glyph,
|
|
5
|
+
* the run's start instant, the live usage, whether the dock is up) and
|
|
6
|
+
* the REPAINT; what a status row SAYS is presentation, and presentation
|
|
7
|
+
* belongs to the terminal layer.
|
|
8
|
+
*
|
|
9
|
+
* Two callers built these rows independently before the move — chat's
|
|
10
|
+
* REPL and the recovery flow — with the running row duplicated verbatim
|
|
11
|
+
* in both. One definition now serves both, and the tier stays a
|
|
12
|
+
* PARAMETER precisely because the two callers disagree on it (chat
|
|
13
|
+
* spells plan's read-only posture out per W19, the recovery flow prints
|
|
14
|
+
* the bare mode): the extraction must not silently unify a difference it
|
|
15
|
+
* was not asked to settle.
|
|
16
|
+
*
|
|
17
|
+
* The rows are byte-for-byte what the CLI built before the move — the
|
|
18
|
+
* v2b/v3 §03 shapes the e2e transcripts pin by substring — with ONE
|
|
19
|
+
* deliberate exception: the running row's interrupt hint, which KC2 §2
|
|
20
|
+
* widens to name the new gesture.
|
|
21
|
+
*/
|
|
22
|
+
/** v3 §03/§05 — the working glyph family; the CLI's 200ms spinner walks
|
|
23
|
+
* it and hands each glyph back to `runningStatus`. */
|
|
24
|
+
export declare const STATUS_GLYPHS: readonly ["▖", "▘", "▝", "▗"];
|
|
25
|
+
/**
|
|
26
|
+
* The RUNNING row: the rotating glyph, the wall seconds since `since`
|
|
27
|
+
* (never below 1 — a run that just started still reads "1s", so the row
|
|
28
|
+
* never claims a turn took no time), the streamed output tokens once the
|
|
29
|
+
* count is known, the interrupt hints, and the live ctx estimate.
|
|
30
|
+
*
|
|
31
|
+
* KC2 §2: the hint names BOTH gestures. Esc still stops; alt+⏎ redirects
|
|
32
|
+
* — stop, and do THIS instead. The row is where the gesture is taught,
|
|
33
|
+
* because it is on screen exactly when the gesture is useful.
|
|
34
|
+
*/
|
|
35
|
+
export declare function runningStatus(glyph: string, since: number, outTokens: number | null, ctxRatio: number): string;
|
|
36
|
+
/** The IDLE row: the approval tier as the CALLER names it, the /mode
|
|
37
|
+
* hint, the model driving the session, and the ctx estimate. */
|
|
38
|
+
export declare function idleStatus(tier: string, model: string, ctxRatio: number): string;
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KC2 §5 — the status line's FORMATTERS, extracted from the CLI (the
|
|
3
|
+
* ADR-0041 escape hatch: extraction, never a fifth raise). The split is
|
|
4
|
+
* the one the ADR names: the CLI keeps the STATE (the rotating glyph,
|
|
5
|
+
* the run's start instant, the live usage, whether the dock is up) and
|
|
6
|
+
* the REPAINT; what a status row SAYS is presentation, and presentation
|
|
7
|
+
* belongs to the terminal layer.
|
|
8
|
+
*
|
|
9
|
+
* Two callers built these rows independently before the move — chat's
|
|
10
|
+
* REPL and the recovery flow — with the running row duplicated verbatim
|
|
11
|
+
* in both. One definition now serves both, and the tier stays a
|
|
12
|
+
* PARAMETER precisely because the two callers disagree on it (chat
|
|
13
|
+
* spells plan's read-only posture out per W19, the recovery flow prints
|
|
14
|
+
* the bare mode): the extraction must not silently unify a difference it
|
|
15
|
+
* was not asked to settle.
|
|
16
|
+
*
|
|
17
|
+
* The rows are byte-for-byte what the CLI built before the move — the
|
|
18
|
+
* v2b/v3 §03 shapes the e2e transcripts pin by substring — with ONE
|
|
19
|
+
* deliberate exception: the running row's interrupt hint, which KC2 §2
|
|
20
|
+
* widens to name the new gesture.
|
|
21
|
+
*/
|
|
22
|
+
import { kUnit } from "./render.js";
|
|
23
|
+
/** v3 §03/§05 — the working glyph family; the CLI's 200ms spinner walks
|
|
24
|
+
* it and hands each glyph back to `runningStatus`. */
|
|
25
|
+
export const STATUS_GLYPHS = ["▖", "▘", "▝", "▗"];
|
|
26
|
+
/** The ~ctx estimate as the whole-percent LEFT. A non-finite ratio (no
|
|
27
|
+
* window, no estimate) yields null and the row prints "~null%" — the
|
|
28
|
+
* long-standing shape, kept on purpose: an honest null beats an
|
|
29
|
+
* invented percentage. */
|
|
30
|
+
function ctxLeft(ratio) {
|
|
31
|
+
return Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The RUNNING row: the rotating glyph, the wall seconds since `since`
|
|
35
|
+
* (never below 1 — a run that just started still reads "1s", so the row
|
|
36
|
+
* never claims a turn took no time), the streamed output tokens once the
|
|
37
|
+
* count is known, the interrupt hints, and the live ctx estimate.
|
|
38
|
+
*
|
|
39
|
+
* KC2 §2: the hint names BOTH gestures. Esc still stops; alt+⏎ redirects
|
|
40
|
+
* — stop, and do THIS instead. The row is where the gesture is taught,
|
|
41
|
+
* because it is on screen exactly when the gesture is useful.
|
|
42
|
+
*/
|
|
43
|
+
export function runningStatus(glyph, since, outTokens, ctxRatio) {
|
|
44
|
+
const out = outTokens !== null ? ` ↓ ${kUnit(outTokens)} tokens` : "";
|
|
45
|
+
const seconds = Math.max(1, Math.round((Date.now() - since) / 1000));
|
|
46
|
+
return `${glyph} working ${seconds}s${out} · esc stop · alt+⏎ redirect · ctx left ~${ctxLeft(ctxRatio)}%`;
|
|
47
|
+
}
|
|
48
|
+
/** The IDLE row: the approval tier as the CALLER names it, the /mode
|
|
49
|
+
* hint, the model driving the session, and the ctx estimate. */
|
|
50
|
+
export function idleStatus(tier, model, ctxRatio) {
|
|
51
|
+
return `▸ ${tier} · /mode to switch · ${model} · ctx left ~${ctxLeft(ctxRatio)}%`;
|
|
52
|
+
}
|
package/dist/strings.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "kiso tui — the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,6 +35,6 @@
|
|
|
35
35
|
},
|
|
36
36
|
"homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tui#readme",
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@vincemakes/kiso-tui-cells": "0.
|
|
38
|
+
"@vincemakes/kiso-tui-cells": "0.7.0"
|
|
39
39
|
}
|
|
40
40
|
}
|