toolcraft 0.0.25 → 0.0.26

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.
Files changed (32) hide show
  1. package/dist/cli.js +11 -9
  2. package/dist/error-report.js +14 -11
  3. package/dist/redaction.d.ts +4 -0
  4. package/dist/redaction.js +70 -0
  5. package/node_modules/@poe-code/config-mutations/dist/execution/apply-mutation.js +33 -9
  6. package/node_modules/@poe-code/config-mutations/dist/formats/json.d.ts +2 -1
  7. package/node_modules/@poe-code/config-mutations/dist/formats/json.js +36 -9
  8. package/node_modules/@poe-code/config-mutations/dist/types.d.ts +2 -0
  9. package/node_modules/@poe-code/design-system/dist/components/browser.js +1 -1
  10. package/node_modules/@poe-code/design-system/dist/explorer/actions.js +1 -1
  11. package/node_modules/@poe-code/design-system/dist/explorer/keymap.js +11 -1
  12. package/node_modules/@poe-code/design-system/dist/explorer/reducer.js +64 -8
  13. package/node_modules/@poe-code/design-system/dist/explorer/render/detail.js +9 -11
  14. package/node_modules/@poe-code/design-system/dist/explorer/render/footer.js +18 -8
  15. package/node_modules/@poe-code/design-system/dist/explorer/render/header.js +11 -18
  16. package/node_modules/@poe-code/design-system/dist/explorer/render/index.js +2 -10
  17. package/node_modules/@poe-code/design-system/dist/explorer/render/list.js +32 -22
  18. package/node_modules/@poe-code/design-system/dist/explorer/render/modal.js +5 -9
  19. package/node_modules/@poe-code/design-system/dist/explorer/render/text.d.ts +12 -0
  20. package/node_modules/@poe-code/design-system/dist/explorer/render/text.js +81 -0
  21. package/node_modules/@poe-code/design-system/dist/explorer/state.d.ts +1 -0
  22. package/node_modules/@poe-code/design-system/dist/explorer/state.js +2 -0
  23. package/node_modules/@poe-code/design-system/dist/prompts/index.js +3 -3
  24. package/node_modules/@poe-code/process-runner/dist/docker/docker-execution-env.js +24 -3
  25. package/node_modules/@poe-code/process-runner/dist/docker/docker-runner.js +1 -0
  26. package/node_modules/auth-store/dist/keychain-store.js +20 -1
  27. package/node_modules/mcp-oauth/dist/client/auth-store-session-store.js +6 -3
  28. package/node_modules/tiny-mcp-client/dist/internal.d.ts +2 -0
  29. package/node_modules/tiny-mcp-client/dist/internal.js +30 -13
  30. package/node_modules/tiny-mcp-client/src/internal.ts +35 -16
  31. package/node_modules/tiny-mcp-client/src/transports.test.ts +68 -0
  32. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { invokeWithHumanInLoop } from "./human-in-loop/index.js";
9
9
  import { resolveMcpProxies } from "./mcp-proxy.js";
10
10
  import { getExpectedNumberDescription, isValidNumberSchemaValue } from "./number-schema.js";
11
11
  import { findEntrypointPackageMetadata } from "./package-metadata.js";
12
+ import { redactHttpBody, redactHttpHeaderValue } from "./redaction.js";
12
13
  import { renderResult } from "./renderer.js";
13
14
  import { renderSourceSnippet } from "./source-snippet.js";
14
15
  import { enableSourceMaps, formatDebugStack } from "./stack-trim.js";
@@ -2586,17 +2587,18 @@ function formatGraphQLErrorEnvelopeBody(body) {
2586
2587
  .join("\n\n");
2587
2588
  }
2588
2589
  function formatHttpErrorBody(body) {
2589
- if (typeof body === "string") {
2590
- return body;
2590
+ const redactedBody = redactHttpBody(body);
2591
+ if (typeof redactedBody === "string") {
2592
+ return redactedBody;
2591
2593
  }
2592
- if (isProblemDetailsLike(body)) {
2593
- return formatProblemDetailsBody(body);
2594
+ if (isProblemDetailsLike(redactedBody)) {
2595
+ return formatProblemDetailsBody(redactedBody);
2594
2596
  }
2595
- if (isGraphQLErrorEnvelopeLike(body)) {
2596
- return formatGraphQLErrorEnvelopeBody(body);
2597
+ if (isGraphQLErrorEnvelopeLike(redactedBody)) {
2598
+ return formatGraphQLErrorEnvelopeBody(redactedBody);
2597
2599
  }
2598
- const serialized = JSON.stringify(body, null, 2);
2599
- return serialized === undefined ? String(body) : serialized;
2600
+ const serialized = JSON.stringify(redactedBody, null, 2);
2601
+ return serialized === undefined ? String(redactedBody) : serialized;
2600
2602
  }
2601
2603
  function indentHttpErrorBlock(value) {
2602
2604
  return value
@@ -2605,7 +2607,7 @@ function indentHttpErrorBlock(value) {
2605
2607
  .join("\n");
2606
2608
  }
2607
2609
  function formatHttpHeaderValue(name, value) {
2608
- return name.toLowerCase() === "authorization" ? "Bearer ****" : value;
2610
+ return redactHttpHeaderValue(name, value);
2609
2611
  }
2610
2612
  function formatHttpErrorHeaders(headers) {
2611
2613
  return Object.entries(headers).map(([name, value]) => ` ${name}: ${formatHttpHeaderValue(name, value)}`);
@@ -6,9 +6,9 @@ import { CommanderError } from "commander";
6
6
  import { ApprovalDeclinedError } from "./human-in-loop/types.js";
7
7
  import { findProjectRoot } from "./mcp-proxy.js";
8
8
  import { findPackageMetadata } from "./package-metadata.js";
9
+ import { isSensitiveName, redactHttpBody, redactHttpHeaderValue } from "./redaction.js";
9
10
  import { UserError } from "./user-error.js";
10
11
  const ERROR_REPORTS_ENV = "TOOLCRAFT_ERROR_REPORTS";
11
- const DEFAULT_SENSITIVE_NAMES = ["password", "token", "apikey", "secret"];
12
12
  function isPlainObject(value) {
13
13
  return typeof value === "object" && value !== null && !Array.isArray(value);
14
14
  }
@@ -116,10 +116,6 @@ function redactValue(value) {
116
116
  }
117
117
  return `<set, ${value.length} chars>`;
118
118
  }
119
- function isSensitiveName(name) {
120
- const normalized = name.toLowerCase();
121
- return DEFAULT_SENSITIVE_NAMES.some((candidate) => normalized.includes(candidate));
122
- }
123
119
  function schemaSecretValue(schema) {
124
120
  const unwrapped = unwrapOptional(schema);
125
121
  if (unwrapped.kind === "string" || unwrapped.kind === "number") {
@@ -208,8 +204,14 @@ function stableJson(value) {
208
204
  return JSON.stringify(value, null, 2) ?? "undefined";
209
205
  }
210
206
  function redactStructuredErrorField(name, value) {
211
- if (typeof value === "string" && name.toLowerCase() === "authorization") {
212
- return "Bearer ****";
207
+ if (typeof value === "string") {
208
+ const redactedHeaderValue = redactHttpHeaderValue(name, value);
209
+ if (redactedHeaderValue !== value) {
210
+ return redactedHeaderValue;
211
+ }
212
+ if (isSensitiveName(name)) {
213
+ return "<redacted>";
214
+ }
213
215
  }
214
216
  if (Array.isArray(value)) {
215
217
  return value.map((entry) => redactStructuredErrorField(name, entry));
@@ -254,7 +256,7 @@ function formatStackChain(error) {
254
256
  return lines.join("\n");
255
257
  }
256
258
  function formatHeaderValue(name, value) {
257
- return name.toLowerCase() === "authorization" ? "Bearer ****" : value;
259
+ return redactHttpHeaderValue(name, value);
258
260
  }
259
261
  function formatHeaders(headers) {
260
262
  return Object.entries(headers)
@@ -262,10 +264,11 @@ function formatHeaders(headers) {
262
264
  .join("\n");
263
265
  }
264
266
  function formatBody(body) {
265
- if (typeof body === "string") {
266
- return body;
267
+ const redactedBody = redactHttpBody(body);
268
+ if (typeof redactedBody === "string") {
269
+ return redactedBody;
267
270
  }
268
- return stableJson(body);
271
+ return stableJson(redactedBody);
269
272
  }
270
273
  function formatHttpTranscript(error) {
271
274
  const requestLines = [
@@ -0,0 +1,4 @@
1
+ export declare function isSensitiveName(name: string): boolean;
2
+ export declare function redactSecretLikeFields(value: unknown, name?: string): unknown;
3
+ export declare function redactHttpBody(body: unknown): unknown;
4
+ export declare function redactHttpHeaderValue(name: string, value: string): string;
@@ -0,0 +1,70 @@
1
+ const REDACTED_VALUE = "<redacted>";
2
+ const SENSITIVE_NAME_PARTS = ["password", "token", "apikey", "secret"];
3
+ const AUTHORIZATION_HEADER_NAMES = new Set(["authorization", "proxyauthorization"]);
4
+ const SECRET_HEADER_NAMES = new Set(["cookie", "setcookie"]);
5
+ function isPlainObject(value) {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+ function normalizeName(name) {
9
+ return name.toLowerCase().replace(/[^a-z0-9]/g, "");
10
+ }
11
+ export function isSensitiveName(name) {
12
+ const normalized = normalizeName(name);
13
+ return SENSITIVE_NAME_PARTS.some((candidate) => normalized.includes(candidate));
14
+ }
15
+ function redactSecretLikeFieldsValue(value, name, seen) {
16
+ if (name.length > 0 && isSensitiveName(name)) {
17
+ return REDACTED_VALUE;
18
+ }
19
+ if (Array.isArray(value)) {
20
+ if (seen.has(value)) {
21
+ return "[Circular]";
22
+ }
23
+ seen.add(value);
24
+ return value.map((entry) => redactSecretLikeFieldsValue(entry, name, seen));
25
+ }
26
+ if (isPlainObject(value)) {
27
+ if (seen.has(value)) {
28
+ return "[Circular]";
29
+ }
30
+ seen.add(value);
31
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
32
+ key,
33
+ redactSecretLikeFieldsValue(entry, key, seen)
34
+ ]));
35
+ }
36
+ return value;
37
+ }
38
+ export function redactSecretLikeFields(value, name = "") {
39
+ return redactSecretLikeFieldsValue(value, name, new WeakSet());
40
+ }
41
+ function parseJsonObjectOrArray(value) {
42
+ const trimmed = value.trim();
43
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
44
+ return undefined;
45
+ }
46
+ try {
47
+ const parsed = JSON.parse(trimmed);
48
+ return isPlainObject(parsed) || Array.isArray(parsed) ? parsed : undefined;
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ export function redactHttpBody(body) {
55
+ if (typeof body === "string") {
56
+ const parsed = parseJsonObjectOrArray(body);
57
+ return parsed === undefined ? body : redactSecretLikeFields(parsed);
58
+ }
59
+ return redactSecretLikeFields(body);
60
+ }
61
+ export function redactHttpHeaderValue(name, value) {
62
+ const normalized = normalizeName(name);
63
+ if (AUTHORIZATION_HEADER_NAMES.has(normalized)) {
64
+ return "Bearer ****";
65
+ }
66
+ if (SECRET_HEADER_NAMES.has(normalized) || isSensitiveName(name)) {
67
+ return REDACTED_VALUE;
68
+ }
69
+ return value;
70
+ }
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { renderTemplate } from "@poe-code/design-system";
3
3
  import { getConfigFormat, detectFormat } from "../formats/index.js";
4
+ import { cloneConfigObject, setConfigEntry } from "../formats/object.js";
4
5
  import { resolvePath } from "./path-utils.js";
5
6
  import { isNotFound, readFileIfExists, pathExists, createTimestamp } from "../fs-utils.js";
6
7
  // ============================================================================
@@ -120,7 +121,7 @@ function pruneKeysByPrefix(table, prefix) {
120
121
  const result = {};
121
122
  for (const [key, value] of Object.entries(table)) {
122
123
  if (!key.startsWith(prefix)) {
123
- result[key] = value;
124
+ setConfigEntry(result, key, value);
124
125
  }
125
126
  }
126
127
  return result;
@@ -129,25 +130,44 @@ function isConfigObject(value) {
129
130
  return typeof value === "object" && value !== null && !Array.isArray(value);
130
131
  }
131
132
  function mergeWithPruneByPrefix(base, patch, pruneByPrefix) {
132
- const result = { ...base };
133
+ const result = cloneConfigObject(base);
133
134
  const prefixMap = pruneByPrefix ?? {};
134
135
  for (const [key, value] of Object.entries(patch)) {
136
+ if (value === undefined) {
137
+ continue;
138
+ }
135
139
  const current = result[key];
136
140
  const prefix = prefixMap[key];
137
141
  if (isConfigObject(current) && isConfigObject(value)) {
138
142
  if (prefix) {
139
143
  const pruned = pruneKeysByPrefix(current, prefix);
140
- result[key] = { ...pruned, ...value };
144
+ setConfigEntry(result, key, mergePrunedConfigObject(pruned, value));
141
145
  }
142
146
  else {
143
- result[key] = mergeWithPruneByPrefix(current, value, prefixMap);
147
+ setConfigEntry(result, key, mergeWithPruneByPrefix(current, value, prefixMap));
144
148
  }
145
149
  continue;
146
150
  }
147
- result[key] = value;
151
+ setConfigEntry(result, key, value);
148
152
  }
149
153
  return result;
150
154
  }
155
+ function mergePrunedConfigObject(base, patch) {
156
+ const result = cloneConfigObject(base);
157
+ for (const [key, value] of Object.entries(patch)) {
158
+ if (value === undefined) {
159
+ continue;
160
+ }
161
+ setConfigEntry(result, key, value);
162
+ }
163
+ return result;
164
+ }
165
+ function serializeConfigUpdate(format, rawContent, current, next) {
166
+ if (rawContent !== null && format.serializeUpdate) {
167
+ return format.serializeUpdate(rawContent, current, next);
168
+ }
169
+ return format.serialize(next);
170
+ }
151
171
  // ============================================================================
152
172
  // Apply Mutation
153
173
  // ============================================================================
@@ -480,6 +500,7 @@ async function applyConfigMerge(mutation, context, options) {
480
500
  }
481
501
  const format = getConfigFormat(formatName);
482
502
  const rawContent = await readFileIfExists(context.fs, targetPath);
503
+ let preserveContent = rawContent;
483
504
  let current;
484
505
  try {
485
506
  current = rawContent === null ? {} : format.parse(rawContent);
@@ -490,9 +511,10 @@ async function applyConfigMerge(mutation, context, options) {
490
511
  await backupInvalidDocument(context, targetPath, rawContent);
491
512
  }
492
513
  current = {};
514
+ preserveContent = null;
493
515
  }
494
516
  const value = resolveValue(mutation.value, options);
495
- // Use mergeWithPruneByPrefix for TOML files with pruneByPrefix option
517
+ // Keep prefix pruning on the same proto-safe object-write path as normal merges.
496
518
  let merged;
497
519
  if (mutation.pruneByPrefix) {
498
520
  merged = mergeWithPruneByPrefix(current, value, mutation.pruneByPrefix);
@@ -500,7 +522,7 @@ async function applyConfigMerge(mutation, context, options) {
500
522
  else {
501
523
  merged = format.merge(current, value);
502
524
  }
503
- const serialized = format.serialize(merged);
525
+ const serialized = serializeConfigUpdate(format, preserveContent, current, merged);
504
526
  const changed = serialized !== rawContent;
505
527
  if (changed && !context.dryRun) {
506
528
  await writeAtomically(context, targetPath, serialized);
@@ -570,7 +592,7 @@ async function applyConfigPrune(mutation, context, options) {
570
592
  details
571
593
  };
572
594
  }
573
- const serialized = format.serialize(result);
595
+ const serialized = serializeConfigUpdate(format, rawContent, current, result);
574
596
  if (!context.dryRun) {
575
597
  await writeAtomically(context, targetPath, serialized);
576
598
  }
@@ -593,6 +615,7 @@ async function applyConfigTransform(mutation, context, options) {
593
615
  }
594
616
  const format = getConfigFormat(formatName);
595
617
  const rawContent = await readFileIfExists(context.fs, targetPath);
618
+ let preserveContent = rawContent;
596
619
  let current;
597
620
  try {
598
621
  current = rawContent === null ? {} : format.parse(rawContent);
@@ -602,6 +625,7 @@ async function applyConfigTransform(mutation, context, options) {
602
625
  await backupInvalidDocument(context, targetPath, rawContent);
603
626
  }
604
627
  current = {};
628
+ preserveContent = null;
605
629
  }
606
630
  const { content: transformed, changed } = mutation.transform(current, options);
607
631
  if (!changed) {
@@ -626,7 +650,7 @@ async function applyConfigTransform(mutation, context, options) {
626
650
  details
627
651
  };
628
652
  }
629
- const serialized = format.serialize(transformed);
653
+ const serialized = serializeConfigUpdate(format, preserveContent, current, transformed);
630
654
  if (!context.dryRun) {
631
655
  await writeAtomically(context, targetPath, serialized);
632
656
  }
@@ -27,5 +27,6 @@ declare function mergePreservingComments(content: string, patch: ConfigObject):
27
27
  * @returns The modified JSON content with comments preserved
28
28
  */
29
29
  declare function removeAtPath(content: string, path: (string | number)[]): string;
30
- export { detectIndent, modifyAtPath, mergePreservingComments, removeAtPath };
30
+ declare function serializeUpdate(content: string, current: ConfigObject, next: ConfigObject): string;
31
+ export { detectIndent, modifyAtPath, mergePreservingComments, removeAtPath, serializeUpdate };
31
32
  export declare const jsonFormat: ConfigFormat;
@@ -48,6 +48,9 @@ function merge(base, patch) {
48
48
  }
49
49
  return result;
50
50
  }
51
+ function configValuesEqual(left, right) {
52
+ return JSON.stringify(left) === JSON.stringify(right);
53
+ }
51
54
  function prune(obj, shape) {
52
55
  let changed = false;
53
56
  const result = cloneConfigObject(obj);
@@ -116,14 +119,8 @@ function modifyAtPath(content, path, value) {
116
119
  * @returns The modified JSON content with comments preserved
117
120
  */
118
121
  function mergePreservingComments(content, patch) {
119
- let result = content || "{}";
120
- for (const [key, value] of Object.entries(patch)) {
121
- if (value === undefined) {
122
- continue;
123
- }
124
- result = modifyAtPath(result, [key], value);
125
- }
126
- return result;
122
+ const current = parse(content);
123
+ return serializeUpdate(content || "{}", current, merge(current, patch));
127
124
  }
128
125
  /**
129
126
  * Remove a key from JSON content while preserving comments and formatting.
@@ -135,10 +132,40 @@ function mergePreservingComments(content, patch) {
135
132
  function removeAtPath(content, path) {
136
133
  return modifyAtPath(content, path, undefined);
137
134
  }
138
- export { detectIndent, modifyAtPath, mergePreservingComments, removeAtPath };
135
+ function serializeUpdate(content, current, next) {
136
+ let result = content || "{}";
137
+ result = applyObjectUpdate(result, [], current, next);
138
+ if (!result.endsWith("\n")) {
139
+ result += "\n";
140
+ }
141
+ return result;
142
+ }
143
+ function applyObjectUpdate(content, path, current, next) {
144
+ let result = content;
145
+ for (const key of Object.keys(current)) {
146
+ if (!hasConfigEntry(next, key)) {
147
+ result = removeAtPath(result, [...path, key]);
148
+ }
149
+ }
150
+ for (const [key, nextValue] of Object.entries(next)) {
151
+ const nextPath = [...path, key];
152
+ const hasCurrent = hasConfigEntry(current, key);
153
+ const currentValue = hasCurrent ? current[key] : undefined;
154
+ if (hasCurrent && isConfigObject(currentValue) && isConfigObject(nextValue)) {
155
+ result = applyObjectUpdate(result, nextPath, currentValue, nextValue);
156
+ continue;
157
+ }
158
+ if (!hasCurrent || !configValuesEqual(currentValue, nextValue)) {
159
+ result = modifyAtPath(result, nextPath, nextValue);
160
+ }
161
+ }
162
+ return result;
163
+ }
164
+ export { detectIndent, modifyAtPath, mergePreservingComments, removeAtPath, serializeUpdate };
139
165
  export const jsonFormat = {
140
166
  parse,
141
167
  serialize,
168
+ serializeUpdate,
142
169
  merge,
143
170
  prune
144
171
  };
@@ -35,6 +35,8 @@ export interface ConfigFormat {
35
35
  parse(content: string): ConfigObject;
36
36
  /** Serialize object to string (with consistent formatting) */
37
37
  serialize(obj: ConfigObject): string;
38
+ /** Serialize an update against original content while preserving comments when possible */
39
+ serializeUpdate?(content: string, current: ConfigObject, next: ConfigObject): string;
38
40
  /** Deep merge patch into base, returning new object */
39
41
  merge(base: ConfigObject, patch: ConfigObject): ConfigObject;
40
42
  /** Remove keys matching shape from object, returning new object */
@@ -10,7 +10,7 @@ function browserCommand(url, platform) {
10
10
  return { command: "open", args: [url] };
11
11
  }
12
12
  if (platform === "win32") {
13
- return { command: "cmd", args: ["/c", "start", "", url] };
13
+ return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
14
14
  }
15
15
  return { command: "xdg-open", args: [url] };
16
16
  }
@@ -32,7 +32,7 @@ function currentDetailItem(state) {
32
32
  return state.detail.items?.[state.detail.cursor];
33
33
  }
34
34
  function selectedRows(state, fallback) {
35
- if (state.selected.size === 0) {
35
+ if (!state.multiSelect || state.selected.size === 0) {
36
36
  return fallback.id === "" ? [] : [fallback];
37
37
  }
38
38
  return state.rows.filter((row) => state.selected.has(row.id));
@@ -45,14 +45,24 @@ const baseBuiltinCommands = [
45
45
  "extendSelectionDown"
46
46
  ];
47
47
  const reorderCommands = ["reorderUp", "reorderDown"];
48
+ const selectionCommands = new Set([
49
+ "toggleSelect",
50
+ "selectAll",
51
+ "clearSelection",
52
+ "extendSelectionUp",
53
+ "extendSelectionDown"
54
+ ]);
48
55
  const reservedActionIds = new Set(["quit"]);
49
56
  export function resolveBindings(config, defaults = {}) {
50
- const commands = config.reorder === undefined
57
+ const baseCommands = config.reorder === undefined
51
58
  ? baseBuiltinCommands
52
59
  : [
53
60
  ...baseBuiltinCommands.filter((command) => command !== "extendSelectionUp" && command !== "extendSelectionDown"),
54
61
  ...reorderCommands
55
62
  ];
63
+ const commands = config.multiSelect === false
64
+ ? baseCommands.filter((command) => !selectionCommands.has(command))
65
+ : baseCommands;
56
66
  const commandBindings = new Map();
57
67
  const flatBindings = new Map();
58
68
  const targetKeys = new Map();
@@ -101,6 +101,9 @@ function stepKey(state, key, runtimeHandles) {
101
101
  if (isBackspace(key)) {
102
102
  return updateFilter(state, state.filter.slice(0, -1));
103
103
  }
104
+ if (!state.multiSelect && isSelectionSpace(key)) {
105
+ return mark(state, 0);
106
+ }
104
107
  if (isPrintable(key)) {
105
108
  return updateFilter(state, `${state.filter}${key.ch}`);
106
109
  }
@@ -170,7 +173,7 @@ function resize(state, cols, rows) {
170
173
  return mark(state, 0);
171
174
  }
172
175
  return {
173
- state: { ...state, size, layout, dirty: REGION_ALL },
176
+ state: clampDetailScroll({ ...state, size, layout, dirty: REGION_ALL }),
174
177
  effects: NO_EFFECTS
175
178
  };
176
179
  }
@@ -186,7 +189,7 @@ function rowsLoaded(state, rows) {
186
189
  const filtered = matches.map((match) => match.index);
187
190
  const matchPositions = createMatchPositions(matches);
188
191
  const cursor = clamp(state.cursor, 0, Math.max(0, filtered.length - 1));
189
- const selected = pruneSelection(state.selected, rows);
192
+ const selected = state.multiSelect ? pruneSelection(state.selected, rows) : new Set();
190
193
  const detail = resetDetailForCursor(state, rows, filtered, cursor);
191
194
  const modal = modalStillValid(state.modal, rows);
192
195
  if (state.modal?.kind === "confirm" && modal === null) {
@@ -239,8 +242,9 @@ function detailItemRendered(state, rowId, token, itemIndex, content) {
239
242
  return mark(state, 0);
240
243
  }
241
244
  const items = state.detail.items.map((item, index) => index === itemIndex ? { ...item, renderedContent: content } : item);
245
+ const detail = { ...state.detail, items };
242
246
  return {
243
- state: { ...state, detail: { ...state.detail, items }, dirty: REGION_DETAIL },
247
+ state: clampDetailScroll({ ...state, detail, dirty: REGION_DETAIL }),
244
248
  effects: NO_EFFECTS
245
249
  };
246
250
  }
@@ -400,6 +404,9 @@ function confirmKey(state, runtimeHandles) {
400
404
  return dispatchPrimary(state, runtimeHandles);
401
405
  }
402
406
  function toggleSelect(state) {
407
+ if (!state.multiSelect) {
408
+ return mark(state, 0);
409
+ }
403
410
  const row = currentRow(state);
404
411
  if (row === undefined) {
405
412
  return mark(state, 0);
@@ -414,6 +421,9 @@ function toggleSelect(state) {
414
421
  return selectionChanged(state, selected);
415
422
  }
416
423
  function selectAll(state) {
424
+ if (!state.multiSelect) {
425
+ return mark(state, 0);
426
+ }
417
427
  const selected = new Set(state.selected);
418
428
  for (const index of state.filtered) {
419
429
  const row = state.rows[index];
@@ -430,13 +440,14 @@ function clearSelection(state) {
430
440
  return selectionChanged(state, new Set());
431
441
  }
432
442
  function selectionChanged(state, selected) {
433
- if (setsEqual(state.selected, selected)) {
443
+ const normalized = state.multiSelect ? selected : new Set();
444
+ if (setsEqual(state.selected, normalized)) {
434
445
  return mark(state, 0);
435
446
  }
436
447
  const next = {
437
448
  ...state,
438
- selected,
439
- actionState: recomputeActionState({ ...state, selected }),
449
+ selected: normalized,
450
+ actionState: recomputeActionState({ ...state, selected: normalized }),
440
451
  dirty: REGION_LIST | REGION_FOOTER
441
452
  };
442
453
  return { state: next, effects: NO_EFFECTS };
@@ -445,7 +456,7 @@ function detailScroll(state, delta) {
445
456
  if (state.focused !== "detail") {
446
457
  return mark(state, 0);
447
458
  }
448
- const scroll = Math.max(0, state.detail.scroll + delta);
459
+ const scroll = clamp(state.detail.scroll + delta, 0, maxDetailScroll(state));
449
460
  if (scroll === state.detail.scroll) {
450
461
  return mark(state, 0);
451
462
  }
@@ -454,7 +465,49 @@ function detailScroll(state, delta) {
454
465
  effects: NO_EFFECTS
455
466
  };
456
467
  }
468
+ function clampDetailScroll(state) {
469
+ const scroll = clamp(state.detail.scroll, 0, maxDetailScroll(state));
470
+ if (scroll === state.detail.scroll) {
471
+ return state;
472
+ }
473
+ return { ...state, detail: { ...state.detail, scroll } };
474
+ }
475
+ function maxDetailScroll(state) {
476
+ const items = state.detail.items;
477
+ if (items === null || items.length === 0) {
478
+ return 0;
479
+ }
480
+ if (items.length === 1 && items[0]?.title === undefined) {
481
+ const visibleHeight = detailBodyHeight(state);
482
+ if (visibleHeight <= 0) {
483
+ return 0;
484
+ }
485
+ return Math.max(0, detailContentLineCount(items[0]) - visibleHeight);
486
+ }
487
+ return Math.max(0, items.length - 1);
488
+ }
489
+ function detailContentLineCount(item) {
490
+ return (item.renderedContent ?? "").split("\n").length;
491
+ }
492
+ function detailBodyHeight(state) {
493
+ if (state.layout === "too-narrow" || state.layout === "narrow-list-only") {
494
+ return 0;
495
+ }
496
+ const rows = normalizeSize(state.size.rows);
497
+ const footerHeight = rows > 0 ? Math.min(1, rows) : 0;
498
+ const headerHeight = Math.min(3, Math.max(0, rows - footerHeight));
499
+ const contentHeight = Math.max(0, rows - headerHeight - footerHeight);
500
+ if (state.layout === "narrow-vertical") {
501
+ const listHeight = Math.ceil(contentHeight / 2);
502
+ const detailHeight = contentHeight - listHeight;
503
+ return Math.max(0, detailHeight - 1);
504
+ }
505
+ return contentHeight;
506
+ }
457
507
  function extendSelection(state, delta) {
508
+ if (!state.multiSelect) {
509
+ return moveCursor(state, delta);
510
+ }
458
511
  const moved = moveCursor(state, delta);
459
512
  const row = currentRow(moved.state);
460
513
  if (row === undefined) {
@@ -646,7 +699,7 @@ function actionSource(state, action) {
646
699
  return state.actionState.get(action.id)?.source ?? "row";
647
700
  }
648
701
  function selectedRows(state) {
649
- if (state.selected.size === 0) {
702
+ if (!state.multiSelect || state.selected.size === 0) {
650
703
  const row = currentRow(state);
651
704
  return row === undefined ? [] : [row];
652
705
  }
@@ -718,6 +771,9 @@ function isPrintable(key) {
718
771
  function isBackspace(key) {
719
772
  return key.name === "backspace" || key.name === "delete";
720
773
  }
774
+ function isSelectionSpace(key) {
775
+ return key.name === "space" || key.ch === " ";
776
+ }
721
777
  function isConfirmYes(key) {
722
778
  return key.ch === "y" || key.ch === "Y";
723
779
  }
@@ -1,4 +1,5 @@
1
1
  import { getExplorerStyles } from "../theme.js";
2
+ import { fitToWidth } from "./text.js";
2
3
  export function renderDetail(state, screen, layout) {
3
4
  const rect = layout.detail;
4
5
  const styles = getExplorerStyles();
@@ -35,7 +36,8 @@ function renderDetailBody(state, screen, rect, row) {
35
36
  function renderListMode(state, screen, rect, items, row) {
36
37
  const styles = getExplorerStyles();
37
38
  let y = 0;
38
- for (let index = state.detail.scroll; index < items.length && y < rect.height; index += 1) {
39
+ const start = clamp(state.detail.scroll, 0, Math.max(0, items.length - 1));
40
+ for (let index = start; index < items.length && y < rect.height; index += 1) {
39
41
  const item = items[index];
40
42
  const cursor = index === state.detail.cursor;
41
43
  const title = item.title ?? item.id;
@@ -60,7 +62,9 @@ function renderListMode(state, screen, rect, items, row) {
60
62
  }
61
63
  }
62
64
  function renderBlob(screen, rect, text, scroll) {
63
- const lines = text.split("\n").slice(scroll);
65
+ const allLines = text.split("\n");
66
+ const start = clamp(scroll, 0, Math.max(0, allLines.length - rect.height));
67
+ const lines = allLines.slice(start);
64
68
  for (let row = 0; row < rect.height; row += 1) {
65
69
  writeLine(screen, rect, row, lines[row] ?? "");
66
70
  }
@@ -86,14 +90,8 @@ function writeLine(screen, rect, row, text, style = {}) {
86
90
  if (row < 0 || row >= rect.height) {
87
91
  return;
88
92
  }
89
- screen.put(rect.x, rect.y + row, fit(text, rect.width), style);
93
+ screen.put(rect.x, rect.y + row, fitToWidth(text, rect.width, rect.x), style);
90
94
  }
91
- function fit(text, width) {
92
- if (width <= 0) {
93
- return "";
94
- }
95
- if (text.length <= width) {
96
- return text;
97
- }
98
- return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
95
+ function clamp(value, min, max) {
96
+ return Math.min(max, Math.max(min, value));
99
97
  }