@sayknow-cli/coding-agent 0.2.3 → 0.2.5

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 (159) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/types/cli/migrate-cli.d.ts +20 -0
  3. package/dist/types/cli/update-cli.d.ts +3 -0
  4. package/dist/types/commands/migrate.d.ts +33 -0
  5. package/dist/types/config/keybindings.d.ts +4 -0
  6. package/dist/types/config/model-registry.d.ts +3 -0
  7. package/dist/types/config/models-config-schema.d.ts +5 -0
  8. package/dist/types/config/settings-schema.d.ts +27 -0
  9. package/dist/types/harness-control-plane/storage.d.ts +2 -1
  10. package/dist/types/hooks/skill-state.d.ts +12 -4
  11. package/dist/types/i18n/messages/en.d.ts +14 -0
  12. package/dist/types/lsp/startup-events.d.ts +1 -0
  13. package/dist/types/migrate/action-planner.d.ts +11 -0
  14. package/dist/types/migrate/adapters/claude-code.d.ts +2 -0
  15. package/dist/types/migrate/adapters/codex.d.ts +5 -0
  16. package/dist/types/migrate/adapters/index.d.ts +45 -0
  17. package/dist/types/migrate/adapters/opencode.d.ts +2 -0
  18. package/dist/types/migrate/executor.d.ts +2 -0
  19. package/dist/types/migrate/mcp-mapper.d.ts +20 -0
  20. package/dist/types/migrate/report.d.ts +18 -0
  21. package/dist/types/migrate/skill-normalizer.d.ts +27 -0
  22. package/dist/types/migrate/types.d.ts +126 -0
  23. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  24. package/dist/types/modes/components/welcome.d.ts +3 -1
  25. package/dist/types/modes/interactive-mode.d.ts +3 -0
  26. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  27. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +1 -1
  28. package/dist/types/research-plan/index.d.ts +1 -0
  29. package/dist/types/research-plan/ledger.d.ts +33 -0
  30. package/dist/types/rlm/artifacts.d.ts +1 -1
  31. package/dist/types/runtime-mcp/config-writer.d.ts +26 -0
  32. package/dist/types/skc-runtime/deep-interview-recorder.d.ts +2 -0
  33. package/dist/types/skc-runtime/deep-interview-runtime.d.ts +2 -2
  34. package/dist/types/skc-runtime/goal-mode-request.d.ts +1 -1
  35. package/dist/types/skc-runtime/session-layout.d.ts +59 -0
  36. package/dist/types/skc-runtime/session-resolution.d.ts +47 -0
  37. package/dist/types/skc-runtime/state-graph.d.ts +1 -1
  38. package/dist/types/skc-runtime/state-runtime.d.ts +5 -4
  39. package/dist/types/skc-runtime/state-schema.d.ts +2 -0
  40. package/dist/types/skc-runtime/state-writer.d.ts +36 -7
  41. package/dist/types/skc-runtime/tmux-sessions.d.ts +2 -0
  42. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +7 -4
  43. package/dist/types/skc-runtime/workflow-command-ref.d.ts +1 -1
  44. package/dist/types/skc-runtime/workflow-manifest.d.ts +1 -1
  45. package/dist/types/skill-state/active-state.d.ts +6 -11
  46. package/dist/types/skill-state/canonical-skills.d.ts +3 -0
  47. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +5 -0
  48. package/dist/types/skill-state/workflow-hud.d.ts +2 -0
  49. package/dist/types/task/spawn-gate.d.ts +1 -10
  50. package/package.json +7 -7
  51. package/scripts/build-binary.ts +0 -7
  52. package/src/cli/migrate-cli.ts +106 -0
  53. package/src/cli/setup-cli.ts +14 -1
  54. package/src/cli/update-cli.ts +53 -3
  55. package/src/cli.ts +1 -0
  56. package/src/commands/deep-interview.ts +2 -2
  57. package/src/commands/launch.ts +1 -1
  58. package/src/commands/migrate.ts +46 -0
  59. package/src/commands/state.ts +2 -1
  60. package/src/commands/team.ts +7 -3
  61. package/src/config/model-registry.ts +9 -2
  62. package/src/config/model-resolver.ts +13 -2
  63. package/src/config/models-config-schema.ts +1 -0
  64. package/src/config/settings-schema.ts +17 -0
  65. package/src/coordinator-mcp/policy.ts +10 -2
  66. package/src/defaults/skc/extensions/grok-cli-vendor/biome.json +0 -1
  67. package/src/defaults/skc/skills/deep-interview/SKILL.md +30 -24
  68. package/src/defaults/skc/skills/ralplan/SKILL.md +10 -4
  69. package/src/defaults/skc/skills/team/SKILL.md +51 -47
  70. package/src/defaults/skc/skills/ultragoal/SKILL.md +17 -13
  71. package/src/exec/bash-executor.ts +3 -1
  72. package/src/extensibility/custom-commands/loader.ts +0 -7
  73. package/src/extensibility/skc-plugins/injection.ts +23 -4
  74. package/src/extensibility/skc-plugins/state.ts +16 -1
  75. package/src/harness-control-plane/storage.ts +14 -4
  76. package/src/hooks/native-skill-hook.ts +38 -12
  77. package/src/hooks/skill-state.ts +178 -83
  78. package/src/i18n/messages/de.ts +14 -0
  79. package/src/i18n/messages/en.ts +14 -0
  80. package/src/i18n/messages/es.ts +14 -0
  81. package/src/i18n/messages/fr.ts +14 -0
  82. package/src/i18n/messages/ja.ts +14 -0
  83. package/src/i18n/messages/ko.ts +14 -0
  84. package/src/i18n/messages/zh.ts +14 -0
  85. package/src/internal-urls/docs-index.generated.ts +11 -9
  86. package/src/lsp/startup-events.ts +24 -0
  87. package/src/migrate/action-planner.ts +318 -0
  88. package/src/migrate/adapters/claude-code.ts +39 -0
  89. package/src/migrate/adapters/codex.ts +70 -0
  90. package/src/migrate/adapters/index.ts +277 -0
  91. package/src/migrate/adapters/opencode.ts +52 -0
  92. package/src/migrate/executor.ts +81 -0
  93. package/src/migrate/mcp-mapper.ts +152 -0
  94. package/src/migrate/report.ts +104 -0
  95. package/src/migrate/skill-normalizer.ts +80 -0
  96. package/src/migrate/types.ts +163 -0
  97. package/src/modes/bridge/bridge-mode.ts +2 -2
  98. package/src/modes/components/custom-editor.ts +30 -20
  99. package/src/modes/components/model-selector.ts +42 -18
  100. package/src/modes/components/welcome.ts +20 -9
  101. package/src/modes/controllers/input-controller.ts +21 -3
  102. package/src/modes/interactive-mode.ts +27 -19
  103. package/src/modes/prompt-action-autocomplete.ts +11 -1
  104. package/src/modes/rpc/rpc-mode.ts +2 -2
  105. package/src/modes/shared/agent-wire/unattended-audit.ts +3 -2
  106. package/src/prompts/agents/init.md +1 -1
  107. package/src/prompts/system/plan-mode-active.md +1 -1
  108. package/src/prompts/tools/ast-grep.md +1 -1
  109. package/src/prompts/tools/search.md +1 -1
  110. package/src/prompts/tools/task.md +1 -2
  111. package/src/research-plan/index.ts +1 -0
  112. package/src/research-plan/ledger.ts +177 -0
  113. package/src/rlm/artifacts.ts +12 -3
  114. package/src/rlm/index.ts +7 -0
  115. package/src/runtime-mcp/config-writer.ts +46 -0
  116. package/src/session/agent-session.ts +43 -41
  117. package/src/session/session-manager.ts +19 -2
  118. package/src/setup/hermes/templates/operator-instructions.v1.md +8 -0
  119. package/src/setup/hermes-setup.ts +1 -1
  120. package/src/skc-runtime/deep-interview-recorder.ts +51 -18
  121. package/src/skc-runtime/deep-interview-runtime.ts +49 -23
  122. package/src/skc-runtime/goal-mode-request.ts +26 -11
  123. package/src/skc-runtime/launch-tmux.ts +68 -15
  124. package/src/skc-runtime/ralplan-runtime.ts +79 -50
  125. package/src/skc-runtime/session-layout.ts +180 -0
  126. package/src/skc-runtime/session-resolution.ts +217 -0
  127. package/src/skc-runtime/state-graph.ts +1 -2
  128. package/src/skc-runtime/state-migrations.ts +1 -0
  129. package/src/skc-runtime/state-runtime.ts +237 -114
  130. package/src/skc-runtime/state-schema.ts +2 -0
  131. package/src/skc-runtime/state-writer.ts +310 -42
  132. package/src/skc-runtime/team-runtime.ts +43 -19
  133. package/src/skc-runtime/tmux-sessions.ts +43 -2
  134. package/src/skc-runtime/ultragoal-guard.ts +45 -2
  135. package/src/skc-runtime/ultragoal-runtime.ts +121 -41
  136. package/src/skc-runtime/workflow-command-ref.ts +1 -2
  137. package/src/skc-runtime/workflow-manifest.ts +1 -2
  138. package/src/skill-state/active-state.ts +116 -129
  139. package/src/skill-state/canonical-skills.ts +4 -0
  140. package/src/skill-state/deep-interview-mutation-guard.ts +238 -111
  141. package/src/skill-state/workflow-hud.ts +4 -2
  142. package/src/skill-state/workflow-state-contract.ts +3 -3
  143. package/src/slash-commands/builtin-registry.ts +8 -4
  144. package/src/system-prompt.ts +11 -9
  145. package/src/task/agents.ts +1 -22
  146. package/src/task/index.ts +1 -41
  147. package/src/task/spawn-gate.ts +1 -38
  148. package/src/task/types.ts +1 -1
  149. package/src/tools/ask.ts +34 -12
  150. package/src/tools/ast-edit.ts +2 -2
  151. package/src/tools/computer.ts +58 -4
  152. package/src/utils/edit-mode.ts +1 -1
  153. package/dist/types/extensibility/custom-commands/bundled/review/index.d.ts +0 -10
  154. package/src/extensibility/custom-commands/bundled/review/index.ts +0 -456
  155. package/src/prompts/agents/explore.md +0 -58
  156. package/src/prompts/agents/plan.md +0 -49
  157. package/src/prompts/agents/reviewer.md +0 -141
  158. package/src/prompts/agents/task.md +0 -16
  159. package/src/prompts/review-request.md +0 -70
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Normalize a skill from another agent into a native SKC `SKILL.md`.
3
+ *
4
+ * SKC derives a skill's loaded name from its directory (`<slug>/SKILL.md`) when no
5
+ * frontmatter `name` is present, and requires a `description`. To guarantee the
6
+ * effective loaded name equals the lowercase-hyphen slug, we drop any frontmatter
7
+ * `name` and place the file at `<slug>/SKILL.md`, synthesizing a `description`
8
+ * when the source lacks one.
9
+ */
10
+
11
+ import { parseFrontmatter } from "@sayknow-cli/utils";
12
+ import { YAML } from "bun";
13
+
14
+ export interface NormalizeSkillInput {
15
+ /** Raw name from the source (filename stem, frontmatter name, etc.). */
16
+ rawName: string;
17
+ /** Full source markdown (may or may not have frontmatter). */
18
+ content: string;
19
+ }
20
+
21
+ export interface NormalizedSkill {
22
+ slug: string;
23
+ content: string;
24
+ warnings: string[];
25
+ }
26
+
27
+ /** Convert an arbitrary name into a lowercase-hyphen slug. */
28
+ export function slugify(name: string): string {
29
+ const slug = name
30
+ .normalize("NFKD")
31
+ .replace(/[^\w\s-]/g, "")
32
+ .trim()
33
+ .toLowerCase()
34
+ .replace(/[\s_]+/g, "-")
35
+ .replace(/-+/g, "-")
36
+ .replace(/^-+|-+$/g, "");
37
+ return slug;
38
+ }
39
+
40
+ function firstNonEmptyLine(body: string): string | undefined {
41
+ for (const raw of body.split("\n")) {
42
+ const line = raw.replace(/^#+\s*/, "").trim();
43
+ if (line) return line;
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ /**
49
+ * Produce a `{ slug, content }` pair whose effective SKC-loaded name equals `slug`.
50
+ * Throws only on an unusable name (cannot produce a slug).
51
+ */
52
+ export function normalizeSkill(input: NormalizeSkillInput): NormalizedSkill {
53
+ const warnings: string[] = [];
54
+ const { frontmatter, body } = parseFrontmatter(input.content, { level: "off" });
55
+
56
+ const sourceName =
57
+ typeof frontmatter.name === "string" && frontmatter.name.trim() ? frontmatter.name : input.rawName;
58
+ const slug = slugify(sourceName);
59
+ if (!slug) {
60
+ throw new Error(`cannot derive a valid slug from skill name "${input.rawName}"`);
61
+ }
62
+ if (slugify(input.rawName) !== slug && typeof frontmatter.name === "string") {
63
+ warnings.push(`renamed skill "${input.rawName}" to slug "${slug}"`);
64
+ }
65
+
66
+ // Build the destination frontmatter: drop `name` (loaded name comes from the dir),
67
+ // keep other fields, and ensure a non-empty description.
68
+ const { name: _droppedName, description: rawDescription, ...rest } = frontmatter;
69
+ let description = typeof rawDescription === "string" ? rawDescription.trim() : "";
70
+ if (!description) {
71
+ description = firstNonEmptyLine(body) ?? `Imported ${slug} skill.`;
72
+ warnings.push(`synthesized description for skill "${slug}"`);
73
+ }
74
+
75
+ const fm: Record<string, unknown> = { description, ...rest };
76
+ const yaml = YAML.stringify(fm).trimEnd();
77
+ const content = `---\n${yaml}\n---\n\n${body.trim()}\n`;
78
+
79
+ return { slug, content, warnings };
80
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Shared types for `skc migrate`.
3
+ *
4
+ * Imports MCP servers and skills from other coding agents (Claude Code, Codex,
5
+ * OpenCode) into native SKC config. See the consensus plan under
6
+ * `.skc/plans/ralplan/` for the full taxonomy and force/collision semantics.
7
+ */
8
+ import type { MCPServerConfig } from "../runtime-mcp/types";
9
+
10
+ /** Supported migration sources. */
11
+ export type MigrateSource = "claude-code" | "codex" | "opencode";
12
+
13
+ export const MIGRATE_SOURCES: readonly MigrateSource[] = ["claude-code", "codex", "opencode"];
14
+
15
+ /** Canonical, deterministic ordering used when expanding `--from all` / repeated `--from`. */
16
+ export const CANONICAL_SOURCE_ORDER: readonly MigrateSource[] = MIGRATE_SOURCES;
17
+
18
+ /** What kind of thing an action/coverage row is about. */
19
+ export type MigrateItemType = "mcp" | "skill" | "source";
20
+
21
+ /**
22
+ * Per-item outcome taxonomy.
23
+ *
24
+ * `skipped_*` outcomes are non-fatal (exit 0). Any `failed_*` outcome sets
25
+ * `ok=false` and a non-zero process exit code.
26
+ */
27
+ export type MigrationStatus =
28
+ | "imported"
29
+ | "updated"
30
+ | "skipped_exists"
31
+ | "skipped_absent_source"
32
+ | "skipped_unmappable"
33
+ | "failed_invalid_source"
34
+ | "failed_invalid_destination"
35
+ | "failed_io";
36
+
37
+ export const MIGRATION_STATUSES: readonly MigrationStatus[] = [
38
+ "imported",
39
+ "updated",
40
+ "skipped_exists",
41
+ "skipped_absent_source",
42
+ "skipped_unmappable",
43
+ "failed_invalid_source",
44
+ "failed_invalid_destination",
45
+ "failed_io",
46
+ ];
47
+
48
+ /** Statuses that represent a hard failure (drive `ok=false` + non-zero exit). */
49
+ export const FAILURE_STATUSES: ReadonlySet<MigrationStatus> = new Set<MigrationStatus>([
50
+ "failed_invalid_source",
51
+ "failed_invalid_destination",
52
+ "failed_io",
53
+ ]);
54
+
55
+ export function isFailureStatus(status: MigrationStatus): boolean {
56
+ return FAILURE_STATUSES.has(status);
57
+ }
58
+
59
+ /** Operation the planner decided for an item. */
60
+ export type MigrateOperation = "create" | "update" | "skip" | "fail";
61
+
62
+ /** A raw MCP server candidate parsed from a source, before mapping/destination planning. */
63
+ export interface McpCandidate {
64
+ source: MigrateSource;
65
+ name: string;
66
+ /** The raw, unmapped server entry from the source config (mapped by the planner). */
67
+ raw: unknown;
68
+ }
69
+
70
+ /** A raw skill candidate parsed from a source, before normalization/destination planning. */
71
+ export interface SkillCandidate {
72
+ source: MigrateSource;
73
+ /** Slug used as the destination directory and effective loaded name. */
74
+ slug: string;
75
+ /** Full SKILL.md content (frontmatter already normalized so loaded name == slug). */
76
+ content: string;
77
+ warnings: string[];
78
+ }
79
+
80
+ /**
81
+ * Source-level diagnostic for a single source/type pair (e.g. "codex mcp config
82
+ * was malformed"). Distinct from per-item actions so absent/unreadable sources
83
+ * are reported once instead of per item.
84
+ */
85
+ export interface SourceDiagnostic {
86
+ source: MigrateSource;
87
+ type: Exclude<MigrateItemType, "source"> | "source";
88
+ status: Extract<MigrationStatus, "skipped_absent_source" | "failed_invalid_source" | "failed_io">;
89
+ message: string;
90
+ }
91
+
92
+ /** Normalized candidates + diagnostics returned by an adapter. */
93
+ export interface AdapterResult {
94
+ mcpCandidates: McpCandidate[];
95
+ skillCandidates: SkillCandidate[];
96
+ diagnostics: SourceDiagnostic[];
97
+ }
98
+
99
+ /** A single planned action consumed identically by dry-run and live execution. */
100
+ export interface MigrateAction {
101
+ source: MigrateSource;
102
+ type: MigrateItemType;
103
+ name?: string;
104
+ /** For skills: the effective SKC-loaded name (== slug). */
105
+ effectiveName?: string;
106
+ /** Absolute destination path (mcp.json for MCP, <skillsDir>/<slug>/SKILL.md for skills). */
107
+ destination?: string;
108
+ operation: MigrateOperation;
109
+ status: MigrationStatus;
110
+ reason?: string;
111
+ warnings?: string[];
112
+ /** Resolved payload the executor needs; never serialized to the report. */
113
+ mcp?: { config: MCPServerConfig; force: boolean };
114
+ skill?: { content: string };
115
+ }
116
+
117
+ export interface MigrateWarning {
118
+ source: MigrateSource;
119
+ type: string;
120
+ name?: string;
121
+ message: string;
122
+ }
123
+
124
+ export type StatusCounts = Record<MigrationStatus, number>;
125
+
126
+ export interface MigrateDestinations {
127
+ mcpConfigPath: string;
128
+ skillsDir: string;
129
+ }
130
+
131
+ /** The full machine-readable report emitted with `--json`. */
132
+ export interface MigrateReport {
133
+ ok: boolean;
134
+ dryRun: boolean;
135
+ project: boolean;
136
+ force: boolean;
137
+ sources: MigrateSource[];
138
+ destinations: MigrateDestinations;
139
+ summary: {
140
+ total: StatusCounts;
141
+ byType: { mcp: StatusCounts; skill: StatusCounts; source: StatusCounts };
142
+ bySource: Record<MigrateSource, StatusCounts>;
143
+ };
144
+ actions: Array<{
145
+ source: MigrateSource;
146
+ type: MigrateItemType;
147
+ name?: string;
148
+ effectiveName?: string;
149
+ destination?: string;
150
+ operation: MigrateOperation;
151
+ status: MigrationStatus;
152
+ reason?: string;
153
+ warnings?: string[];
154
+ }>;
155
+ warnings: MigrateWarning[];
156
+ }
157
+
158
+ /** Create a zeroed status-count record. */
159
+ export function emptyStatusCounts(): StatusCounts {
160
+ const counts = {} as StatusCounts;
161
+ for (const status of MIGRATION_STATUSES) counts[status] = 0;
162
+ return counts;
163
+ }
@@ -1,7 +1,7 @@
1
- import * as path from "node:path";
2
1
  import type { ExtensionUIContext } from "../../extensibility/extensions";
3
2
  import type { AgentSession } from "../../session/agent-session";
4
3
  import type { ClientBridgePermissionOutcome } from "../../session/client-bridge";
4
+ import { workflowGatePath } from "../../skc-runtime/session-layout";
5
5
  import type { RpcCommand, RpcResponse, RpcWorkflowGateResponse } from "../rpc/rpc-types";
6
6
  import { dispatchRpcCommand } from "../shared/agent-wire/command-dispatch";
7
7
  import { isRpcCommand } from "../shared/agent-wire/command-validation";
@@ -611,7 +611,7 @@ export async function runBridgeMode(
611
611
  });
612
612
  };
613
613
  const gateStore = new FileGateStore(
614
- path.join(session.sessionManager.getCwd(), ".skc", "state", "workflow-gates", `${session.sessionId}.json`),
614
+ workflowGatePath(session.sessionManager.getCwd(), session.sessionId, session.sessionId),
615
615
  );
616
616
  const unattendedControlPlane = new UnattendedSessionControlPlane({
617
617
  runId: session.sessionId,
@@ -1,6 +1,6 @@
1
1
  import { Editor, type KeyId, matchesKey, parseKittySequence } from "@sayknow-cli/tui";
2
2
  import { BracketedPasteHandler } from "@sayknow-cli/tui/bracketed-paste";
3
- import type { AppKeybinding } from "../../config/keybindings";
3
+ import { type AppKeybinding, KEYBINDINGS } from "../../config/keybindings";
4
4
 
5
5
  type ConfigurableEditorAction = Extract<
6
6
  AppKeybinding,
@@ -23,25 +23,35 @@ type ConfigurableEditorAction = Extract<
23
23
  | "app.clipboard.copyPrompt"
24
24
  >;
25
25
 
26
- const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
27
- "app.interrupt": ["escape"],
28
- "app.clear": ["ctrl+c"],
29
- "app.exit": ["ctrl+d"],
30
- "app.suspend": ["ctrl+z"],
31
- "app.thinking.cycle": ["shift+tab"],
32
- "app.model.cycleForward": ["ctrl+p"],
33
- "app.model.cycleBackward": ["shift+ctrl+p"],
34
- "app.model.select": ["ctrl+l"],
35
- "app.model.selectTemporary": ["alt+p"],
36
- "app.tools.expand": ["ctrl+o"],
37
- "app.thinking.toggle": ["ctrl+t"],
38
- "app.editor.external": ["ctrl+g"],
39
- "app.history.search": ["ctrl+r"],
40
- "app.message.queue": ["alt+enter"],
41
- "app.message.dequeue": ["alt+up"],
42
- "app.clipboard.pasteImage": ["ctrl+v"],
43
- "app.clipboard.copyPrompt": ["alt+shift+c"],
44
- };
26
+ // Editor-configurable app actions. Defaults are derived from the central
27
+ // KEYBINDINGS registry so there is a single source of truth (e.g. the
28
+ // platform-aware app.clipboard.pasteImage default is not duplicated here).
29
+ const CONFIGURABLE_EDITOR_ACTIONS = [
30
+ "app.interrupt",
31
+ "app.clear",
32
+ "app.exit",
33
+ "app.suspend",
34
+ "app.thinking.cycle",
35
+ "app.model.cycleForward",
36
+ "app.model.cycleBackward",
37
+ "app.model.select",
38
+ "app.model.selectTemporary",
39
+ "app.tools.expand",
40
+ "app.thinking.toggle",
41
+ "app.editor.external",
42
+ "app.history.search",
43
+ "app.message.queue",
44
+ "app.message.dequeue",
45
+ "app.clipboard.pasteImage",
46
+ "app.clipboard.copyPrompt",
47
+ ] as const satisfies readonly ConfigurableEditorAction[];
48
+
49
+ const DEFAULT_ACTION_KEYS = Object.fromEntries(
50
+ CONFIGURABLE_EDITOR_ACTIONS.map(action => {
51
+ const defaultKeys = KEYBINDINGS[action].defaultKeys;
52
+ return [action, Array.isArray(defaultKeys) ? [...defaultKeys] : [defaultKeys]];
53
+ }),
54
+ ) as Record<ConfigurableEditorAction, KeyId[]>;
45
55
 
46
56
  const PASTE_DECISION_TIMEOUT_MS = 5_000;
47
57
  const PENDING_PASTE_INPUT_MAX = 64;
@@ -29,6 +29,7 @@ import {
29
29
  type ScopedModelSelection,
30
30
  } from "../../config/model-resolver";
31
31
  import type { Settings } from "../../config/settings";
32
+ import { t } from "../../i18n";
32
33
  import { type ThemeColor, theme } from "../../modes/theme/theme";
33
34
  import { formatModelOnboardingInlineHint } from "../../setup/model-onboarding-guidance";
34
35
  import { formatClampedModelSelector, getThinkingLevelMetadata, parseThinkingLevel } from "../../thinking";
@@ -190,7 +191,7 @@ const PROFILE_ROLE_PREVIEW_ORDER: SkcModelAssignmentTargetId[] = [
190
191
  "critic",
191
192
  "architect",
192
193
  ];
193
- const PRESET_SCOPE_LABELS = ["Apply for this session", "Set as default"];
194
+ const presetScopeLabels = (): readonly string[] => [t("modelSelector.applySession"), t("modelSelector.setDefault")];
194
195
 
195
196
  function isPrintableCharacter(keyData: string): boolean {
196
197
  return keyData.length === 1 && keyData >= " " && keyData !== "\x7f";
@@ -290,8 +291,7 @@ export class ModelSelectorComponent extends Container {
290
291
  this.addChild(new Spacer(1));
291
292
 
292
293
  // Add hint about model filtering
293
- const hintText =
294
- scopedModels.length > 0 ? "Showing models from --models scope" : formatModelOnboardingInlineHint();
294
+ const hintText = scopedModels.length > 0 ? t("modelSelector.showingScoped") : formatModelOnboardingInlineHint();
295
295
  this.addChild(new Text(theme.fg("warning", hintText), 0, 0));
296
296
  this.addChild(new Spacer(1));
297
297
 
@@ -594,7 +594,7 @@ export class ModelSelectorComponent extends Container {
594
594
  this.#headerContainer.clear();
595
595
 
596
596
  const tabs: Tab[] = this.#providers.map(provider => ({ id: provider.id, label: provider.label }));
597
- const tabBar = new TabBar("Models", tabs, getTabBarTheme(), this.#activeTabIndex);
597
+ const tabBar = new TabBar(t("modelSelector.modelsTab"), tabs, getTabBarTheme(), this.#activeTabIndex);
598
598
  tabBar.onTabChange = (_tab, index) => {
599
599
  this.#activeTabIndex = index;
600
600
  this.#selectedIndex = 0;
@@ -830,19 +830,19 @@ export class ModelSelectorComponent extends Container {
830
830
  this.#headerContainer.clear();
831
831
  this.#tabBar = null;
832
832
  this.#listContainer.clear();
833
- this.#headerContainer.addChild(new Text(theme.fg("accent", "Model presets"), 0, 0));
833
+ this.#headerContainer.addChild(new Text(theme.fg("accent", t("modelSelector.presets")), 0, 0));
834
834
  const rows = this.#getPresetRows();
835
835
  for (let i = 0; i < rows.length; i++) {
836
836
  const row = rows[i];
837
837
  const selected = i === this.#presetCursor;
838
838
  const prefix = selected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
839
839
  if (row.kind === "create") {
840
- const label = "Create custom preset";
840
+ const label = t("modelSelector.createCustom");
841
841
  this.#listContainer.addChild(new Text(`${prefix}${selected ? theme.fg("accent", label) : label}`, 0, 0));
842
842
  continue;
843
843
  }
844
844
  if (row.kind === "browse") {
845
- const label = "Browse all models";
845
+ const label = t("modelSelector.browseAll");
846
846
  this.#listContainer.addChild(new Text(`${prefix}${selected ? theme.fg("accent", label) : label}`, 0, 0));
847
847
  continue;
848
848
  }
@@ -872,7 +872,14 @@ export class ModelSelectorComponent extends Container {
872
872
  #renderPresetPreview(profile: ModelProfileDefinition): void {
873
873
  this.#listContainer.addChild(new Spacer(1));
874
874
  this.#listContainer.addChild(
875
- new Text(theme.fg("muted", ` Preset preview: ${getModelProfilePresentation(profile).displayName}`), 0, 0),
875
+ new Text(
876
+ theme.fg(
877
+ "muted",
878
+ ` ${t("modelSelector.presetPreview", { name: getModelProfilePresentation(profile).displayName })}`,
879
+ ),
880
+ 0,
881
+ 0,
882
+ ),
876
883
  );
877
884
  for (const role of PROFILE_ROLE_PREVIEW_ORDER) {
878
885
  const selector = profile.modelMapping[role];
@@ -889,15 +896,15 @@ export class ModelSelectorComponent extends Container {
889
896
  }
890
897
  this.#listContainer.addChild(new Spacer(1));
891
898
  if (this.#presetScopeMenuOpen) {
892
- for (let i = 0; i < PRESET_SCOPE_LABELS.length; i++) {
893
- const label = PRESET_SCOPE_LABELS[i] ?? "";
899
+ for (let i = 0; i < presetScopeLabels().length; i++) {
900
+ const label = presetScopeLabels()[i] ?? "";
894
901
  const prefix = i === this.#presetScopeIndex ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
895
902
  this.#listContainer.addChild(
896
903
  new Text(`${prefix}${i === this.#presetScopeIndex ? theme.fg("accent", label) : label}`, 0, 0),
897
904
  );
898
905
  }
899
906
  } else {
900
- this.#listContainer.addChild(new Text(theme.fg("muted", " Press Enter to apply this preset"), 0, 0));
907
+ this.#listContainer.addChild(new Text(theme.fg("muted", ` ${t("modelSelector.pressEnterApply")}`), 0, 0));
901
908
  }
902
909
  }
903
910
 
@@ -1050,7 +1057,10 @@ export class ModelSelectorComponent extends Container {
1050
1057
  const statusMessage = this.#getProviderEmptyStateMessage();
1051
1058
  this.#listContainer.addChild(
1052
1059
  new Text(
1053
- theme.fg("muted", statusMessage ?? ` No matching models. ${formatModelOnboardingInlineHint()}`),
1060
+ theme.fg(
1061
+ "muted",
1062
+ statusMessage ?? ` ${t("modelSelector.noMatching")} ${formatModelOnboardingInlineHint()}`,
1063
+ ),
1054
1064
  0,
1055
1065
  0,
1056
1066
  ),
@@ -1065,7 +1075,11 @@ export class ModelSelectorComponent extends Container {
1065
1075
  ? ` (${selected.model.provider}/${selected.model.id}, ${(selected as CanonicalModelItem).variantCount} variants)`
1066
1076
  : "";
1067
1077
  this.#listContainer.addChild(
1068
- new Text(theme.fg("muted", ` Model Name: ${selected.model.name}${suffix}`), 0, 0),
1078
+ new Text(
1079
+ theme.fg("muted", ` ${t("modelSelector.modelName", { value: selected.model.name })}${suffix}`),
1080
+ 0,
1081
+ 0,
1082
+ ),
1069
1083
  );
1070
1084
  if (this.#pendingThinkingChoice) {
1071
1085
  this.#renderThinkingMenu(this.#pendingThinkingChoice);
@@ -1077,7 +1091,9 @@ export class ModelSelectorComponent extends Container {
1077
1091
 
1078
1092
  #renderActionMenu(item: ModelItem | CanonicalModelItem): void {
1079
1093
  this.#listContainer.addChild(new Spacer(1));
1080
- this.#listContainer.addChild(new Text(theme.fg("muted", ` Action for: ${item.model.id}`), 0, 0));
1094
+ this.#listContainer.addChild(
1095
+ new Text(theme.fg("muted", ` ${t("modelSelector.actionFor", { id: item.model.id })}`), 0, 0),
1096
+ );
1081
1097
  this.#listContainer.addChild(new Spacer(1));
1082
1098
  const actionCount = this.#getActionCount(item.model);
1083
1099
  for (let i = 0; i < actionCount; i++) {
@@ -1091,10 +1107,18 @@ export class ModelSelectorComponent extends Container {
1091
1107
  }
1092
1108
 
1093
1109
  #renderThinkingMenu(choice: PendingThinkingChoice): void {
1094
- const targetLabel = choice.role === null ? "temporary model" : SKC_MODEL_ASSIGNMENT_TARGETS[choice.role].name;
1110
+ const targetLabel =
1111
+ choice.role === null ? t("modelSelector.temporaryModel") : SKC_MODEL_ASSIGNMENT_TARGETS[choice.role].name;
1095
1112
  this.#listContainer.addChild(new Spacer(1));
1096
1113
  this.#listContainer.addChild(
1097
- new Text(theme.fg("muted", ` Reasoning for ${targetLabel}: ${choice.item.model.id}`), 0, 0),
1114
+ new Text(
1115
+ theme.fg(
1116
+ "muted",
1117
+ ` ${t("modelSelector.reasoningFor", { target: targetLabel, id: choice.item.model.id })}`,
1118
+ ),
1119
+ 0,
1120
+ 0,
1121
+ ),
1098
1122
  );
1099
1123
  this.#listContainer.addChild(new Spacer(1));
1100
1124
  for (let i = 0; i < choice.levels.length; i++) {
@@ -1188,7 +1212,7 @@ export class ModelSelectorComponent extends Container {
1188
1212
  if (rows.length === 0) return;
1189
1213
  if (this.#presetScopeMenuOpen) {
1190
1214
  this.#presetScopeIndex =
1191
- this.#presetScopeIndex === 0 ? PRESET_SCOPE_LABELS.length - 1 : this.#presetScopeIndex - 1;
1215
+ this.#presetScopeIndex === 0 ? presetScopeLabels().length - 1 : this.#presetScopeIndex - 1;
1192
1216
  } else {
1193
1217
  this.#presetCursor = this.#presetCursor === 0 ? rows.length - 1 : this.#presetCursor - 1;
1194
1218
  this.#previewProfileName = undefined;
@@ -1202,7 +1226,7 @@ export class ModelSelectorComponent extends Container {
1202
1226
  const rows = this.#getPresetRows();
1203
1227
  if (rows.length === 0) return;
1204
1228
  if (this.#presetScopeMenuOpen) {
1205
- this.#presetScopeIndex = (this.#presetScopeIndex + 1) % PRESET_SCOPE_LABELS.length;
1229
+ this.#presetScopeIndex = (this.#presetScopeIndex + 1) % presetScopeLabels().length;
1206
1230
  } else {
1207
1231
  this.#presetCursor = (this.#presetCursor + 1) % rows.length;
1208
1232
  this.#previewProfileName = undefined;
@@ -14,6 +14,8 @@ export interface LspServerInfo {
14
14
  fileTypes: string[];
15
15
  }
16
16
 
17
+ export type WelcomeLogoMode = "unicode" | "square" | "ascii";
18
+
17
19
  /**
18
20
  * Sayknow-CLI launch surface: a blue-gradient SAYKNOW wordmark, compact
19
21
  * command affordances, and project signals — a distinct identity, not a
@@ -29,6 +31,7 @@ export class WelcomeComponent implements Component {
29
31
  private providerName: string,
30
32
  private recentSessions: RecentSession[] = [],
31
33
  private lspServers: LspServerInfo[] = [],
34
+ private readonly logoMode: WelcomeLogoMode = "unicode",
32
35
  ) {}
33
36
 
34
37
  invalidate(): void {}
@@ -85,7 +88,8 @@ export class WelcomeComponent implements Component {
85
88
  const minRightCol = 20;
86
89
  const modelPill = this.#pill(theme.icon.model || "model", this.modelName, "statusLineModel");
87
90
  const providerPill = this.#pill(theme.icon.package || "provider", this.providerName, "statusLinePath");
88
- const logoMinWidth = Math.max(...BRAND_LOGO.map(line => visibleWidth(line)));
91
+ const logoLines = this.#logoLines();
92
+ const logoMinWidth = Math.max(...logoLines.map(line => visibleWidth(line)));
89
93
  const leftMinContentWidth = Math.max(
90
94
  minLeftCol,
91
95
  logoMinWidth,
@@ -103,8 +107,7 @@ export class WelcomeComponent implements Component {
103
107
  const leftCol = showRightColumn ? dualLeftCol : boxWidth - 2;
104
108
  const rightCol = showRightColumn ? dualRightCol : 0;
105
109
 
106
- // Logo: pick a frame from the intro animation if active, else the resting frame.
107
- const logoColored = this.#currentLogoFrame();
110
+ const logoColored = this.#currentLogoFrame(logoLines);
108
111
 
109
112
  // When no model is resolved yet, guide the user instead of showing "Unknown".
110
113
  const hasModel = this.modelName !== "Unknown" && this.modelName.length > 0;
@@ -281,10 +284,10 @@ export class WelcomeComponent implements Component {
281
284
  }
282
285
 
283
286
  /** Pick the logo frame for the current intro phase, or the resting frame. */
284
- #currentLogoFrame(): readonly string[] {
285
- if (this.#animStart == null) return REST_FRAME;
287
+ #currentLogoFrame(logoLines: readonly string[]): readonly string[] {
288
+ if (this.#animStart == null) return REST_FRAMES[this.logoMode];
286
289
  const elapsed = performance.now() - this.#animStart;
287
- if (elapsed >= INTRO_MS) return REST_FRAME;
290
+ if (elapsed >= INTRO_MS) return REST_FRAMES[this.logoMode];
288
291
  // Ease-out cubic so the spin decelerates into the resting state.
289
292
  const progress = elapsed / INTRO_MS;
290
293
  const eased = 1 - (1 - progress) ** 3;
@@ -296,7 +299,11 @@ export class WelcomeComponent implements Component {
296
299
  // the same ease-out curve so the highlight is gone by the resting frame.
297
300
  const shinePos = (((progress * INTRO_SHINE_TRAVERSALS) % 1) + 1) % 1;
298
301
  const shineStrength = (1 - eased) ** 1.5;
299
- return gradientLogo(BRAND_LOGO, phase, { strength: shineStrength, pos: shinePos });
302
+ return gradientLogo(logoLines, phase, { strength: shineStrength, pos: shinePos });
303
+ }
304
+
305
+ #logoLines(): readonly string[] {
306
+ return BRAND_LOGO;
300
307
  }
301
308
  }
302
309
 
@@ -405,5 +412,9 @@ const INTRO_SWEEPS = 2.5;
405
412
  /** Number of times the shine highlight crosses the diagonal across the intro. */
406
413
  const INTRO_SHINE_TRAVERSALS = 3;
407
414
 
408
- /** Resting gradient frame, cached for re-renders outside of the intro. */
409
- const REST_FRAME = gradientLogo(BRAND_LOGO, 0);
415
+ /** Resting gradient frames, cached for re-renders outside of the intro. */
416
+ const REST_FRAMES: Record<WelcomeLogoMode, readonly string[]> = {
417
+ unicode: gradientLogo(BRAND_LOGO, 0),
418
+ square: gradientLogo(BRAND_LOGO, 0),
419
+ ascii: gradientLogo(BRAND_LOGO, 0),
420
+ };
@@ -199,6 +199,9 @@ export class InputController {
199
199
  this.ctx.editor.onDequeue = () => this.handleDequeue();
200
200
  this.ctx.editor.setActionKeys("app.message.queue", this.ctx.keybindings.getKeys("app.message.queue"));
201
201
  this.ctx.editor.onQueue = () => void this.handleQueueSubmit();
202
+ this.ctx.editor.onTabDeclined = () => {
203
+ if (this.ctx.session.isStreaming) void this.handleQueueSubmit();
204
+ };
202
205
 
203
206
  this.ctx.editor.clearCustomKeyHandlers();
204
207
  // Wire up extension shortcuts
@@ -228,7 +231,11 @@ export class InputController {
228
231
  });
229
232
  }
230
233
  for (const key of this.ctx.keybindings.getKeys("app.message.followUp")) {
231
- this.ctx.editor.setCustomKeyHandler(key, () => void this.handleFollowUp());
234
+ this.ctx.editor.setCustomKeyHandler(key, () => {
235
+ if (!this.#isFollowUpShortcutActive()) return false;
236
+ void this.handleFollowUp();
237
+ return true;
238
+ });
232
239
  }
233
240
  for (const key of this.ctx.keybindings.getKeys("app.stt.toggle")) {
234
241
  this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
@@ -503,6 +510,15 @@ export class InputController {
503
510
  return this.ctx.settings.get("busyPromptMode") === "steer" ? "steer" : "followUp";
504
511
  }
505
512
 
513
+ #isFollowUpShortcutActive(): boolean {
514
+ return (
515
+ this.ctx.session.isStreaming ||
516
+ this.ctx.session.isCompacting ||
517
+ this.ctx.session.isBashRunning ||
518
+ this.ctx.session.isEvalRunning
519
+ );
520
+ }
521
+
506
522
  /**
507
523
  * Dispatch skill slash invocation(s) (`/skill:<name>`) through custom messages
508
524
  * using the supplied `streamingBehavior`. Returns true if the text was a
@@ -824,8 +840,9 @@ export class InputController {
824
840
  this.ctx.ui.requestRender();
825
841
  return true;
826
842
  }
827
- // No image in clipboard - show hint
828
- this.ctx.showStatus("No image in clipboard (use terminal paste for text)");
843
+ this.ctx.showStatus(
844
+ "No image in clipboard. Use #paste-image, paste a copied image, or attach an image file with @path/to/image.png.",
845
+ );
829
846
  return false;
830
847
  } catch {
831
848
  this.ctx.showStatus("Failed to read clipboard");
@@ -840,6 +857,7 @@ export class InputController {
840
857
  keybindings: this.ctx.keybindings,
841
858
  copyCurrentLine: () => this.handleCopyCurrentLine(),
842
859
  copyPrompt: () => this.handleCopyPrompt(),
860
+ pasteImage: () => void this.handleImagePaste(),
843
861
  undo: prefix => this.ctx.editor.undoPastTransientText(prefix),
844
862
  moveCursorToMessageEnd: () => this.ctx.editor.moveToMessageEnd(),
845
863
  moveCursorToMessageStart: () => this.ctx.editor.moveToMessageStart(),