@kolisachint/hoocode-agent 0.5.57 → 0.5.59
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/CHANGELOG.md +181 -0
- package/dist/core/agent-session.d.ts +2 -2
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +4 -3
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/extensions/runner.d.ts.map +1 -1
- package/dist/core/extensions/runner.js +4 -1
- package/dist/core/extensions/runner.js.map +1 -1
- package/dist/core/keybindings.d.ts +162 -98
- package/dist/core/keybindings.d.ts.map +1 -1
- package/dist/core/keybindings.js +222 -106
- package/dist/core/keybindings.js.map +1 -1
- package/dist/modes/interactive/command-executor.d.ts.map +1 -1
- package/dist/modes/interactive/command-executor.js +95 -62
- package/dist/modes/interactive/command-executor.js.map +1 -1
- package/dist/modes/interactive/components/ask-options.d.ts +20 -6
- package/dist/modes/interactive/components/ask-options.d.ts.map +1 -1
- package/dist/modes/interactive/components/ask-options.js +35 -13
- package/dist/modes/interactive/components/ask-options.js.map +1 -1
- package/dist/modes/interactive/components/keybinding-hints.d.ts +9 -0
- package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
- package/dist/modes/interactive/components/keybinding-hints.js +12 -0
- package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
- package/dist/modes/interactive/components/task-panel.d.ts +2 -2
- package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
- package/dist/modes/interactive/components/task-panel.js +24 -11
- package/dist/modes/interactive/components/task-panel.js.map +1 -1
- package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/tree-selector.js +29 -12
- package/dist/modes/interactive/components/tree-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +63 -8
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +189 -90
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/model-controller.d.ts +3 -0
- package/dist/modes/interactive/model-controller.d.ts.map +1 -1
- package/dist/modes/interactive/model-controller.js +1 -1
- package/dist/modes/interactive/model-controller.js.map +1 -1
- package/docs/keybindings.md +247 -80
- package/docs/terminal-setup.md +8 -3
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { Model } from "@kolisachint/hoocode-ai";
|
|
11
11
|
import type { Component, TUI } from "@kolisachint/hoocode-tui";
|
|
12
12
|
import type { AgentSession } from "../../core/agent-session.js";
|
|
13
|
+
import type { AppKeybinding } from "../../core/keybindings.js";
|
|
13
14
|
/** The slice of the interactive mode the model flows need. */
|
|
14
15
|
export interface ModelControllerDeps {
|
|
15
16
|
ui: TUI;
|
|
@@ -20,6 +21,8 @@ export interface ModelControllerDeps {
|
|
|
20
21
|
focus: Component;
|
|
21
22
|
}): void;
|
|
22
23
|
showStatus(message: string): void;
|
|
24
|
+
/** Report a dial step, naming its reverse key the first time. */
|
|
25
|
+
showDialStep(backward: AppKeybinding, message: string): void;
|
|
23
26
|
showError(errorMessage: string): void;
|
|
24
27
|
showWarning(warningMessage: string): void;
|
|
25
28
|
/** A filled notice, for warnings that cost money if ignored. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"model-controller.d.ts","sourceRoot":"","sources":["../../../src/modes/interactive/model-controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,0BAA0B,CAAC;AAC/D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAehE,8DAA8D;AAC9D,MAAM,WAAW,mBAAmB;IACnC,EAAE,EAAE,GAAG,CAAC;IACR,0EAA0E;IAC1E,IAAI,OAAO,IAAI,YAAY,CAAC;IAC5B,YAAY,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,IAAI,KAAK;QAAE,SAAS,EAAE,SAAS,CAAC;QAAC,KAAK,EAAE,SAAS,CAAA;KAAE,GAAG,IAAI,CAAC;IAC7F,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,SAAS,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,WAAW,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,gEAAgE;IAChE,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAChD,uBAAuB,IAAI,IAAI,CAAC;IAChC,gBAAgB,IAAI,IAAI,CAAC;IACzB,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/C;AAED,qBAAa,eAAe;IAGf,OAAO,CAAC,QAAQ,CAAC,IAAI;IAFjC,OAAO,CAAC,iCAAiC,CAAS;IAElD,YAA6B,IAAI,EAAE,mBAAmB,EAAI;IAE1D,OAAO,KAAK,OAAO,GAElB;IAEK,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,CAAC,CAG7E;YAEa,kBAAkB;IAahC,iFAAiF;IAC3E,4BAA4B,IAAI,OAAO,CAAC,IAAI,CAAC,CAIlD;YAGa,6BAA6B;IAsB3C;;;;OAIG;IACG,uCAAuC,CAAC,KAAK,GAAE,KAAK,CAAC,GAAG,CAAC,GAAG,SAA8B,GAAG,OAAO,CAAC,IAAI,CAAC,CAS/G;IAEK,UAAU,CAAC,SAAS,EAAE,SAAS,GAAG,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBjE;IAED,iBAAiB,CAAC,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CA6BnD;IAEK,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CA2ExC;CACD","sourcesContent":["/**\n * Model selection for the interactive mode (/model, /models, cycle keys).\n *\n * Owns the single-model picker, the scoped-models (enable set) picker, model\n * cycling, exact-match lookup for slash-command arguments, the footer's\n * available-provider count, and the Anthropic subscription-auth warning.\n * Extracted from interactive-mode.ts behind a narrow ModelControllerDeps\n * interface.\n */\n\nimport type { Model } from \"@kolisachint/hoocode-ai\";\nimport type { Component, TUI } from \"@kolisachint/hoocode-tui\";\nimport type { AgentSession } from \"../../core/agent-session.js\";\nimport { findExactModelReferenceMatch, resolveModelScope } from \"../../core/model-resolver.js\";\nimport { ModelSelectorComponent } from \"./components/model-selector.js\";\nimport { ScopedModelsSelectorComponent } from \"./components/scoped-models-selector.js\";\n\nconst ANTHROPIC_SUBSCRIPTION_AUTH_TITLE = \"Anthropic subscription\";\nconst ANTHROPIC_SUBSCRIPTION_AUTH_BODY = [\n\t\"Billed per token as extra usage, not against plan limits.\",\n\t\"Turn off in /settings → Anthropic extra usage.\",\n];\n\nfunction isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean {\n\treturn typeof apiKey === \"string\" && apiKey.startsWith(\"sk-ant-oat\");\n}\n\n/** The slice of the interactive mode the model flows need. */\nexport interface ModelControllerDeps {\n\tui: TUI;\n\t/** The active session (read at call time; the session can be swapped). */\n\tget session(): AgentSession;\n\tshowSelector(create: (done: () => void) => { component: Component; focus: Component }): void;\n\tshowStatus(message: string): void;\n\tshowError(errorMessage: string): void;\n\tshowWarning(warningMessage: string): void;\n\t/** A filled notice, for warnings that cost money if ignored. */\n\tshowNotice(title: string, body: string[]): void;\n\tupdateEditorBorderColor(): void;\n\tinvalidateFooter(): void;\n\tsetAvailableProviderCount(count: number): void;\n}\n\nexport class ModelController {\n\tprivate anthropicSubscriptionWarningShown = false;\n\n\tconstructor(private readonly deps: ModelControllerDeps) {}\n\n\tprivate get session(): AgentSession {\n\t\treturn this.deps.session;\n\t}\n\n\tasync findExactModelMatch(searchTerm: string): Promise<Model<any> | undefined> {\n\t\tconst models = await this.getModelCandidates();\n\t\treturn findExactModelReferenceMatch(searchTerm, models);\n\t}\n\n\tprivate async getModelCandidates(): Promise<Model<any>[]> {\n\t\tif (this.session.scopedModels.length > 0) {\n\t\t\treturn this.session.scopedModels.map((scoped) => scoped.model);\n\t\t}\n\n\t\tthis.session.modelRegistry.refresh();\n\t\ttry {\n\t\t\treturn await this.session.modelRegistry.getAvailable();\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t}\n\n\t/** Update the footer's available provider count from current model candidates */\n\tasync updateAvailableProviderCount(): Promise<void> {\n\t\tconst models = await this.getModelCandidates();\n\t\tconst uniqueProviders = new Set(models.map((m) => m.provider));\n\t\tthis.deps.setAvailableProviderCount(uniqueProviders.size);\n\t}\n\n\t/** Whether the model bills through Anthropic subscription auth (extra usage). */\n\tprivate async usesAnthropicSubscriptionAuth(model: Model<any> | undefined): Promise<boolean> {\n\t\tif (this.session.settingsManager.getWarnings().anthropicExtraUsage === false) {\n\t\t\treturn false;\n\t\t}\n\t\tif (!model || model.provider !== \"anthropic\") {\n\t\t\treturn false;\n\t\t}\n\n\t\tconst storedCredential = this.session.modelRegistry.authStorage.get(\"anthropic\");\n\t\tif (storedCredential?.type === \"oauth\") {\n\t\t\treturn true;\n\t\t}\n\n\t\ttry {\n\t\t\tconst apiKey = await this.session.modelRegistry.getApiKeyForProvider(model.provider);\n\t\t\treturn isAnthropicSubscriptionAuthKey(apiKey);\n\t\t} catch {\n\t\t\t// Ignore auth lookup failures for warning-only checks.\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/**\n\t * Resolving the auth type can hit the keychain, so every caller fires this\n\t * without awaiting it; the once-per-session latch is what keeps the notice\n\t * from repeating, not the call site.\n\t */\n\tasync maybeWarnAboutAnthropicSubscriptionAuth(model: Model<any> | undefined = this.session.model): Promise<void> {\n\t\tif (this.anthropicSubscriptionWarningShown) {\n\t\t\treturn;\n\t\t}\n\t\tif (!(await this.usesAnthropicSubscriptionAuth(model))) {\n\t\t\treturn;\n\t\t}\n\t\tthis.anthropicSubscriptionWarningShown = true;\n\t\tthis.deps.showNotice(ANTHROPIC_SUBSCRIPTION_AUTH_TITLE, ANTHROPIC_SUBSCRIPTION_AUTH_BODY);\n\t}\n\n\tasync cycleModel(direction: \"forward\" | \"backward\"): Promise<void> {\n\t\ttry {\n\t\t\tconst result = await this.session.cycleModel(direction);\n\t\t\tif (result === undefined) {\n\t\t\t\tconst msg = this.session.scopedModels.length > 0 ? \"Only one model in scope\" : \"Only one model available\";\n\t\t\t\tthis.deps.showStatus(msg);\n\t\t\t} else {\n\t\t\t\tthis.deps.invalidateFooter();\n\t\t\t\tthis.deps.updateEditorBorderColor();\n\t\t\t\tconst thinkingStr =\n\t\t\t\t\tresult.model.reasoning && result.thinkingLevel !== \"off\" ? ` (thinking: ${result.thinkingLevel})` : \"\";\n\t\t\t\tthis.deps.showStatus(`Switched to ${result.model.name || result.model.id}${thinkingStr}`);\n\t\t\t\tvoid this.maybeWarnAboutAnthropicSubscriptionAuth(result.model);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tthis.deps.showError(error instanceof Error ? error.message : String(error));\n\t\t}\n\t}\n\n\tshowModelSelector(initialSearchInput?: string): void {\n\t\tthis.deps.showSelector((done) => {\n\t\t\tconst selector = new ModelSelectorComponent(\n\t\t\t\tthis.deps.ui,\n\t\t\t\tthis.session.model,\n\t\t\t\tthis.session.settingsManager,\n\t\t\t\tthis.session.modelRegistry,\n\t\t\t\tthis.session.scopedModels,\n\t\t\t\tasync (model) => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait this.session.setModel(model);\n\t\t\t\t\t\tthis.deps.invalidateFooter();\n\t\t\t\t\t\tthis.deps.updateEditorBorderColor();\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.deps.showStatus(`Model: ${model.id}`);\n\t\t\t\t\t\tvoid this.maybeWarnAboutAnthropicSubscriptionAuth(model);\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.deps.showError(error instanceof Error ? error.message : String(error));\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\tdone();\n\t\t\t\t\tthis.deps.ui.requestRender();\n\t\t\t\t},\n\t\t\t\tinitialSearchInput,\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector };\n\t\t});\n\t}\n\n\tasync showModelsSelector(): Promise<void> {\n\t\t// Get all available models\n\t\tthis.session.modelRegistry.refresh();\n\t\tconst allModels = this.session.modelRegistry.getAvailable();\n\n\t\tif (allModels.length === 0) {\n\t\t\tthis.deps.showStatus(\"No models available\");\n\t\t\treturn;\n\t\t}\n\n\t\t// Check if session has scoped models (from previous session-only changes or CLI --models)\n\t\tconst sessionScopedModels = this.session.scopedModels;\n\t\tconst hasSessionScope = sessionScopedModels.length > 0;\n\n\t\t// Build enabled model IDs from session state or settings\n\t\tlet currentEnabledIds: string[] | null = null;\n\n\t\tif (hasSessionScope) {\n\t\t\t// Use current session's scoped models\n\t\t\tcurrentEnabledIds = sessionScopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);\n\t\t} else {\n\t\t\t// Fall back to settings\n\t\t\tconst patterns = this.session.settingsManager.getEnabledModels();\n\t\t\tif (patterns !== undefined && patterns.length > 0) {\n\t\t\t\tconst scopedModels = await resolveModelScope(patterns, this.session.modelRegistry);\n\t\t\t\tcurrentEnabledIds = scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);\n\t\t\t}\n\t\t}\n\n\t\t// Helper to update session's scoped models (session-only, no persist)\n\t\tconst updateSessionModels = async (enabledIds: string[] | null) => {\n\t\t\tcurrentEnabledIds = enabledIds === null ? null : [...enabledIds];\n\t\t\tif (enabledIds && enabledIds.length > 0 && enabledIds.length < allModels.length) {\n\t\t\t\tconst newScopedModels = await resolveModelScope(enabledIds, this.session.modelRegistry);\n\t\t\t\tthis.session.setScopedModels(\n\t\t\t\t\tnewScopedModels.map((sm) => ({\n\t\t\t\t\t\tmodel: sm.model,\n\t\t\t\t\t\tthinkingLevel: sm.thinkingLevel,\n\t\t\t\t\t})),\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\t// All enabled or none enabled = no filter\n\t\t\t\tthis.session.setScopedModels([]);\n\t\t\t}\n\t\t\tawait this.updateAvailableProviderCount();\n\t\t\tthis.deps.ui.requestRender();\n\t\t};\n\n\t\tthis.deps.showSelector((done) => {\n\t\t\tconst selector = new ScopedModelsSelectorComponent(\n\t\t\t\t{\n\t\t\t\t\tallModels,\n\t\t\t\t\tenabledModelIds: currentEnabledIds,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tonChange: async (enabledIds) => {\n\t\t\t\t\t\tawait updateSessionModels(enabledIds);\n\t\t\t\t\t},\n\t\t\t\t\tonPersist: (enabledIds) => {\n\t\t\t\t\t\t// Persist to settings\n\t\t\t\t\t\tconst newPatterns =\n\t\t\t\t\t\t\tenabledIds === null || enabledIds.length === allModels.length\n\t\t\t\t\t\t\t\t? undefined // All enabled = clear filter\n\t\t\t\t\t\t\t\t: enabledIds;\n\t\t\t\t\t\tthis.session.settingsManager.setEnabledModels(newPatterns ? [...newPatterns] : undefined);\n\t\t\t\t\t\tthis.deps.showStatus(\"Model selection saved to settings\");\n\t\t\t\t\t},\n\t\t\t\t\tonCancel: () => {\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.deps.ui.requestRender();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector };\n\t\t});\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"model-controller.d.ts","sourceRoot":"","sources":["../../../src/modes/interactive/model-controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,0BAA0B,CAAC;AAC/D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAe/D,8DAA8D;AAC9D,MAAM,WAAW,mBAAmB;IACnC,EAAE,EAAE,GAAG,CAAC;IACR,0EAA0E;IAC1E,IAAI,OAAO,IAAI,YAAY,CAAC;IAC5B,YAAY,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,IAAI,KAAK;QAAE,SAAS,EAAE,SAAS,CAAC;QAAC,KAAK,EAAE,SAAS,CAAA;KAAE,GAAG,IAAI,CAAC;IAC7F,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,iEAAiE;IACjE,YAAY,CAAC,QAAQ,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7D,SAAS,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,WAAW,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,gEAAgE;IAChE,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAChD,uBAAuB,IAAI,IAAI,CAAC;IAChC,gBAAgB,IAAI,IAAI,CAAC;IACzB,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/C;AAED,qBAAa,eAAe;IAGf,OAAO,CAAC,QAAQ,CAAC,IAAI;IAFjC,OAAO,CAAC,iCAAiC,CAAS;IAElD,YAA6B,IAAI,EAAE,mBAAmB,EAAI;IAE1D,OAAO,KAAK,OAAO,GAElB;IAEK,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,CAAC,CAG7E;YAEa,kBAAkB;IAahC,iFAAiF;IAC3E,4BAA4B,IAAI,OAAO,CAAC,IAAI,CAAC,CAIlD;YAGa,6BAA6B;IAsB3C;;;;OAIG;IACG,uCAAuC,CAAC,KAAK,GAAE,KAAK,CAAC,GAAG,CAAC,GAAG,SAA8B,GAAG,OAAO,CAAC,IAAI,CAAC,CAS/G;IAEK,UAAU,CAAC,SAAS,EAAE,SAAS,GAAG,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAoBjE;IAED,iBAAiB,CAAC,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CA6BnD;IAEK,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CA2ExC;CACD","sourcesContent":["/**\n * Model selection for the interactive mode (/model, /models, cycle keys).\n *\n * Owns the single-model picker, the scoped-models (enable set) picker, model\n * cycling, exact-match lookup for slash-command arguments, the footer's\n * available-provider count, and the Anthropic subscription-auth warning.\n * Extracted from interactive-mode.ts behind a narrow ModelControllerDeps\n * interface.\n */\n\nimport type { Model } from \"@kolisachint/hoocode-ai\";\nimport type { Component, TUI } from \"@kolisachint/hoocode-tui\";\nimport type { AgentSession } from \"../../core/agent-session.js\";\nimport type { AppKeybinding } from \"../../core/keybindings.js\";\nimport { findExactModelReferenceMatch, resolveModelScope } from \"../../core/model-resolver.js\";\nimport { ModelSelectorComponent } from \"./components/model-selector.js\";\nimport { ScopedModelsSelectorComponent } from \"./components/scoped-models-selector.js\";\n\nconst ANTHROPIC_SUBSCRIPTION_AUTH_TITLE = \"Anthropic subscription\";\nconst ANTHROPIC_SUBSCRIPTION_AUTH_BODY = [\n\t\"Billed per token as extra usage, not against plan limits.\",\n\t\"Turn off in /settings → Anthropic extra usage.\",\n];\n\nfunction isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean {\n\treturn typeof apiKey === \"string\" && apiKey.startsWith(\"sk-ant-oat\");\n}\n\n/** The slice of the interactive mode the model flows need. */\nexport interface ModelControllerDeps {\n\tui: TUI;\n\t/** The active session (read at call time; the session can be swapped). */\n\tget session(): AgentSession;\n\tshowSelector(create: (done: () => void) => { component: Component; focus: Component }): void;\n\tshowStatus(message: string): void;\n\t/** Report a dial step, naming its reverse key the first time. */\n\tshowDialStep(backward: AppKeybinding, message: string): void;\n\tshowError(errorMessage: string): void;\n\tshowWarning(warningMessage: string): void;\n\t/** A filled notice, for warnings that cost money if ignored. */\n\tshowNotice(title: string, body: string[]): void;\n\tupdateEditorBorderColor(): void;\n\tinvalidateFooter(): void;\n\tsetAvailableProviderCount(count: number): void;\n}\n\nexport class ModelController {\n\tprivate anthropicSubscriptionWarningShown = false;\n\n\tconstructor(private readonly deps: ModelControllerDeps) {}\n\n\tprivate get session(): AgentSession {\n\t\treturn this.deps.session;\n\t}\n\n\tasync findExactModelMatch(searchTerm: string): Promise<Model<any> | undefined> {\n\t\tconst models = await this.getModelCandidates();\n\t\treturn findExactModelReferenceMatch(searchTerm, models);\n\t}\n\n\tprivate async getModelCandidates(): Promise<Model<any>[]> {\n\t\tif (this.session.scopedModels.length > 0) {\n\t\t\treturn this.session.scopedModels.map((scoped) => scoped.model);\n\t\t}\n\n\t\tthis.session.modelRegistry.refresh();\n\t\ttry {\n\t\t\treturn await this.session.modelRegistry.getAvailable();\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t}\n\n\t/** Update the footer's available provider count from current model candidates */\n\tasync updateAvailableProviderCount(): Promise<void> {\n\t\tconst models = await this.getModelCandidates();\n\t\tconst uniqueProviders = new Set(models.map((m) => m.provider));\n\t\tthis.deps.setAvailableProviderCount(uniqueProviders.size);\n\t}\n\n\t/** Whether the model bills through Anthropic subscription auth (extra usage). */\n\tprivate async usesAnthropicSubscriptionAuth(model: Model<any> | undefined): Promise<boolean> {\n\t\tif (this.session.settingsManager.getWarnings().anthropicExtraUsage === false) {\n\t\t\treturn false;\n\t\t}\n\t\tif (!model || model.provider !== \"anthropic\") {\n\t\t\treturn false;\n\t\t}\n\n\t\tconst storedCredential = this.session.modelRegistry.authStorage.get(\"anthropic\");\n\t\tif (storedCredential?.type === \"oauth\") {\n\t\t\treturn true;\n\t\t}\n\n\t\ttry {\n\t\t\tconst apiKey = await this.session.modelRegistry.getApiKeyForProvider(model.provider);\n\t\t\treturn isAnthropicSubscriptionAuthKey(apiKey);\n\t\t} catch {\n\t\t\t// Ignore auth lookup failures for warning-only checks.\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/**\n\t * Resolving the auth type can hit the keychain, so every caller fires this\n\t * without awaiting it; the once-per-session latch is what keeps the notice\n\t * from repeating, not the call site.\n\t */\n\tasync maybeWarnAboutAnthropicSubscriptionAuth(model: Model<any> | undefined = this.session.model): Promise<void> {\n\t\tif (this.anthropicSubscriptionWarningShown) {\n\t\t\treturn;\n\t\t}\n\t\tif (!(await this.usesAnthropicSubscriptionAuth(model))) {\n\t\t\treturn;\n\t\t}\n\t\tthis.anthropicSubscriptionWarningShown = true;\n\t\tthis.deps.showNotice(ANTHROPIC_SUBSCRIPTION_AUTH_TITLE, ANTHROPIC_SUBSCRIPTION_AUTH_BODY);\n\t}\n\n\tasync cycleModel(direction: \"forward\" | \"backward\"): Promise<void> {\n\t\ttry {\n\t\t\tconst result = await this.session.cycleModel(direction);\n\t\t\tif (result === undefined) {\n\t\t\t\tconst msg = this.session.scopedModels.length > 0 ? \"Only one model in scope\" : \"Only one model available\";\n\t\t\t\tthis.deps.showStatus(msg);\n\t\t\t} else {\n\t\t\t\tthis.deps.invalidateFooter();\n\t\t\t\tthis.deps.updateEditorBorderColor();\n\t\t\t\tconst thinkingStr =\n\t\t\t\t\tresult.model.reasoning && result.thinkingLevel !== \"off\" ? ` (thinking: ${result.thinkingLevel})` : \"\";\n\t\t\t\tthis.deps.showDialStep(\n\t\t\t\t\tdirection === \"forward\" ? \"app.model.cycleBackward\" : \"app.model.cycleForward\",\n\t\t\t\t\t`Switched to ${result.model.name || result.model.id}${thinkingStr}`,\n\t\t\t\t);\n\t\t\t\tvoid this.maybeWarnAboutAnthropicSubscriptionAuth(result.model);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tthis.deps.showError(error instanceof Error ? error.message : String(error));\n\t\t}\n\t}\n\n\tshowModelSelector(initialSearchInput?: string): void {\n\t\tthis.deps.showSelector((done) => {\n\t\t\tconst selector = new ModelSelectorComponent(\n\t\t\t\tthis.deps.ui,\n\t\t\t\tthis.session.model,\n\t\t\t\tthis.session.settingsManager,\n\t\t\t\tthis.session.modelRegistry,\n\t\t\t\tthis.session.scopedModels,\n\t\t\t\tasync (model) => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait this.session.setModel(model);\n\t\t\t\t\t\tthis.deps.invalidateFooter();\n\t\t\t\t\t\tthis.deps.updateEditorBorderColor();\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.deps.showStatus(`Model: ${model.id}`);\n\t\t\t\t\t\tvoid this.maybeWarnAboutAnthropicSubscriptionAuth(model);\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.deps.showError(error instanceof Error ? error.message : String(error));\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\tdone();\n\t\t\t\t\tthis.deps.ui.requestRender();\n\t\t\t\t},\n\t\t\t\tinitialSearchInput,\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector };\n\t\t});\n\t}\n\n\tasync showModelsSelector(): Promise<void> {\n\t\t// Get all available models\n\t\tthis.session.modelRegistry.refresh();\n\t\tconst allModels = this.session.modelRegistry.getAvailable();\n\n\t\tif (allModels.length === 0) {\n\t\t\tthis.deps.showStatus(\"No models available\");\n\t\t\treturn;\n\t\t}\n\n\t\t// Check if session has scoped models (from previous session-only changes or CLI --models)\n\t\tconst sessionScopedModels = this.session.scopedModels;\n\t\tconst hasSessionScope = sessionScopedModels.length > 0;\n\n\t\t// Build enabled model IDs from session state or settings\n\t\tlet currentEnabledIds: string[] | null = null;\n\n\t\tif (hasSessionScope) {\n\t\t\t// Use current session's scoped models\n\t\t\tcurrentEnabledIds = sessionScopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);\n\t\t} else {\n\t\t\t// Fall back to settings\n\t\t\tconst patterns = this.session.settingsManager.getEnabledModels();\n\t\t\tif (patterns !== undefined && patterns.length > 0) {\n\t\t\t\tconst scopedModels = await resolveModelScope(patterns, this.session.modelRegistry);\n\t\t\t\tcurrentEnabledIds = scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);\n\t\t\t}\n\t\t}\n\n\t\t// Helper to update session's scoped models (session-only, no persist)\n\t\tconst updateSessionModels = async (enabledIds: string[] | null) => {\n\t\t\tcurrentEnabledIds = enabledIds === null ? null : [...enabledIds];\n\t\t\tif (enabledIds && enabledIds.length > 0 && enabledIds.length < allModels.length) {\n\t\t\t\tconst newScopedModels = await resolveModelScope(enabledIds, this.session.modelRegistry);\n\t\t\t\tthis.session.setScopedModels(\n\t\t\t\t\tnewScopedModels.map((sm) => ({\n\t\t\t\t\t\tmodel: sm.model,\n\t\t\t\t\t\tthinkingLevel: sm.thinkingLevel,\n\t\t\t\t\t})),\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\t// All enabled or none enabled = no filter\n\t\t\t\tthis.session.setScopedModels([]);\n\t\t\t}\n\t\t\tawait this.updateAvailableProviderCount();\n\t\t\tthis.deps.ui.requestRender();\n\t\t};\n\n\t\tthis.deps.showSelector((done) => {\n\t\t\tconst selector = new ScopedModelsSelectorComponent(\n\t\t\t\t{\n\t\t\t\t\tallModels,\n\t\t\t\t\tenabledModelIds: currentEnabledIds,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tonChange: async (enabledIds) => {\n\t\t\t\t\t\tawait updateSessionModels(enabledIds);\n\t\t\t\t\t},\n\t\t\t\t\tonPersist: (enabledIds) => {\n\t\t\t\t\t\t// Persist to settings\n\t\t\t\t\t\tconst newPatterns =\n\t\t\t\t\t\t\tenabledIds === null || enabledIds.length === allModels.length\n\t\t\t\t\t\t\t\t? undefined // All enabled = clear filter\n\t\t\t\t\t\t\t\t: enabledIds;\n\t\t\t\t\t\tthis.session.settingsManager.setEnabledModels(newPatterns ? [...newPatterns] : undefined);\n\t\t\t\t\t\tthis.deps.showStatus(\"Model selection saved to settings\");\n\t\t\t\t\t},\n\t\t\t\t\tonCancel: () => {\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.deps.ui.requestRender();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector };\n\t\t});\n\t}\n}\n"]}
|
|
@@ -96,7 +96,7 @@ export class ModelController {
|
|
|
96
96
|
this.deps.invalidateFooter();
|
|
97
97
|
this.deps.updateEditorBorderColor();
|
|
98
98
|
const thinkingStr = result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
|
|
99
|
-
this.deps.
|
|
99
|
+
this.deps.showDialStep(direction === "forward" ? "app.model.cycleBackward" : "app.model.cycleForward", `Switched to ${result.model.name || result.model.id}${thinkingStr}`);
|
|
100
100
|
void this.maybeWarnAboutAnthropicSubscriptionAuth(result.model);
|
|
101
101
|
}
|
|
102
102
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"model-controller.js","sourceRoot":"","sources":["../../../src/modes/interactive/model-controller.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Model selection for the interactive mode (/model, /models, cycle keys).\n *\n * Owns the single-model picker, the scoped-models (enable set) picker, model\n * cycling, exact-match lookup for slash-command arguments, the footer's\n * available-provider count, and the Anthropic subscription-auth warning.\n * Extracted from interactive-mode.ts behind a narrow ModelControllerDeps\n * interface.\n */\n\nimport type { Model } from \"@kolisachint/hoocode-ai\";\nimport type { Component, TUI } from \"@kolisachint/hoocode-tui\";\nimport type { AgentSession } from \"../../core/agent-session.js\";\nimport { findExactModelReferenceMatch, resolveModelScope } from \"../../core/model-resolver.js\";\nimport { ModelSelectorComponent } from \"./components/model-selector.js\";\nimport { ScopedModelsSelectorComponent } from \"./components/scoped-models-selector.js\";\n\nconst ANTHROPIC_SUBSCRIPTION_AUTH_TITLE = \"Anthropic subscription\";\nconst ANTHROPIC_SUBSCRIPTION_AUTH_BODY = [\n\t\"Billed per token as extra usage, not against plan limits.\",\n\t\"Turn off in /settings → Anthropic extra usage.\",\n];\n\nfunction isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean {\n\treturn typeof apiKey === \"string\" && apiKey.startsWith(\"sk-ant-oat\");\n}\n\n/** The slice of the interactive mode the model flows need. */\nexport interface ModelControllerDeps {\n\tui: TUI;\n\t/** The active session (read at call time; the session can be swapped). */\n\tget session(): AgentSession;\n\tshowSelector(create: (done: () => void) => { component: Component; focus: Component }): void;\n\tshowStatus(message: string): void;\n\tshowError(errorMessage: string): void;\n\tshowWarning(warningMessage: string): void;\n\t/** A filled notice, for warnings that cost money if ignored. */\n\tshowNotice(title: string, body: string[]): void;\n\tupdateEditorBorderColor(): void;\n\tinvalidateFooter(): void;\n\tsetAvailableProviderCount(count: number): void;\n}\n\nexport class ModelController {\n\tprivate anthropicSubscriptionWarningShown = false;\n\n\tconstructor(private readonly deps: ModelControllerDeps) {}\n\n\tprivate get session(): AgentSession {\n\t\treturn this.deps.session;\n\t}\n\n\tasync findExactModelMatch(searchTerm: string): Promise<Model<any> | undefined> {\n\t\tconst models = await this.getModelCandidates();\n\t\treturn findExactModelReferenceMatch(searchTerm, models);\n\t}\n\n\tprivate async getModelCandidates(): Promise<Model<any>[]> {\n\t\tif (this.session.scopedModels.length > 0) {\n\t\t\treturn this.session.scopedModels.map((scoped) => scoped.model);\n\t\t}\n\n\t\tthis.session.modelRegistry.refresh();\n\t\ttry {\n\t\t\treturn await this.session.modelRegistry.getAvailable();\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t}\n\n\t/** Update the footer's available provider count from current model candidates */\n\tasync updateAvailableProviderCount(): Promise<void> {\n\t\tconst models = await this.getModelCandidates();\n\t\tconst uniqueProviders = new Set(models.map((m) => m.provider));\n\t\tthis.deps.setAvailableProviderCount(uniqueProviders.size);\n\t}\n\n\t/** Whether the model bills through Anthropic subscription auth (extra usage). */\n\tprivate async usesAnthropicSubscriptionAuth(model: Model<any> | undefined): Promise<boolean> {\n\t\tif (this.session.settingsManager.getWarnings().anthropicExtraUsage === false) {\n\t\t\treturn false;\n\t\t}\n\t\tif (!model || model.provider !== \"anthropic\") {\n\t\t\treturn false;\n\t\t}\n\n\t\tconst storedCredential = this.session.modelRegistry.authStorage.get(\"anthropic\");\n\t\tif (storedCredential?.type === \"oauth\") {\n\t\t\treturn true;\n\t\t}\n\n\t\ttry {\n\t\t\tconst apiKey = await this.session.modelRegistry.getApiKeyForProvider(model.provider);\n\t\t\treturn isAnthropicSubscriptionAuthKey(apiKey);\n\t\t} catch {\n\t\t\t// Ignore auth lookup failures for warning-only checks.\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/**\n\t * Resolving the auth type can hit the keychain, so every caller fires this\n\t * without awaiting it; the once-per-session latch is what keeps the notice\n\t * from repeating, not the call site.\n\t */\n\tasync maybeWarnAboutAnthropicSubscriptionAuth(model: Model<any> | undefined = this.session.model): Promise<void> {\n\t\tif (this.anthropicSubscriptionWarningShown) {\n\t\t\treturn;\n\t\t}\n\t\tif (!(await this.usesAnthropicSubscriptionAuth(model))) {\n\t\t\treturn;\n\t\t}\n\t\tthis.anthropicSubscriptionWarningShown = true;\n\t\tthis.deps.showNotice(ANTHROPIC_SUBSCRIPTION_AUTH_TITLE, ANTHROPIC_SUBSCRIPTION_AUTH_BODY);\n\t}\n\n\tasync cycleModel(direction: \"forward\" | \"backward\"): Promise<void> {\n\t\ttry {\n\t\t\tconst result = await this.session.cycleModel(direction);\n\t\t\tif (result === undefined) {\n\t\t\t\tconst msg = this.session.scopedModels.length > 0 ? \"Only one model in scope\" : \"Only one model available\";\n\t\t\t\tthis.deps.showStatus(msg);\n\t\t\t} else {\n\t\t\t\tthis.deps.invalidateFooter();\n\t\t\t\tthis.deps.updateEditorBorderColor();\n\t\t\t\tconst thinkingStr =\n\t\t\t\t\tresult.model.reasoning && result.thinkingLevel !== \"off\" ? ` (thinking: ${result.thinkingLevel})` : \"\";\n\t\t\t\tthis.deps.showStatus(`Switched to ${result.model.name || result.model.id}${thinkingStr}`);\n\t\t\t\tvoid this.maybeWarnAboutAnthropicSubscriptionAuth(result.model);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tthis.deps.showError(error instanceof Error ? error.message : String(error));\n\t\t}\n\t}\n\n\tshowModelSelector(initialSearchInput?: string): void {\n\t\tthis.deps.showSelector((done) => {\n\t\t\tconst selector = new ModelSelectorComponent(\n\t\t\t\tthis.deps.ui,\n\t\t\t\tthis.session.model,\n\t\t\t\tthis.session.settingsManager,\n\t\t\t\tthis.session.modelRegistry,\n\t\t\t\tthis.session.scopedModels,\n\t\t\t\tasync (model) => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait this.session.setModel(model);\n\t\t\t\t\t\tthis.deps.invalidateFooter();\n\t\t\t\t\t\tthis.deps.updateEditorBorderColor();\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.deps.showStatus(`Model: ${model.id}`);\n\t\t\t\t\t\tvoid this.maybeWarnAboutAnthropicSubscriptionAuth(model);\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.deps.showError(error instanceof Error ? error.message : String(error));\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\tdone();\n\t\t\t\t\tthis.deps.ui.requestRender();\n\t\t\t\t},\n\t\t\t\tinitialSearchInput,\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector };\n\t\t});\n\t}\n\n\tasync showModelsSelector(): Promise<void> {\n\t\t// Get all available models\n\t\tthis.session.modelRegistry.refresh();\n\t\tconst allModels = this.session.modelRegistry.getAvailable();\n\n\t\tif (allModels.length === 0) {\n\t\t\tthis.deps.showStatus(\"No models available\");\n\t\t\treturn;\n\t\t}\n\n\t\t// Check if session has scoped models (from previous session-only changes or CLI --models)\n\t\tconst sessionScopedModels = this.session.scopedModels;\n\t\tconst hasSessionScope = sessionScopedModels.length > 0;\n\n\t\t// Build enabled model IDs from session state or settings\n\t\tlet currentEnabledIds: string[] | null = null;\n\n\t\tif (hasSessionScope) {\n\t\t\t// Use current session's scoped models\n\t\t\tcurrentEnabledIds = sessionScopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);\n\t\t} else {\n\t\t\t// Fall back to settings\n\t\t\tconst patterns = this.session.settingsManager.getEnabledModels();\n\t\t\tif (patterns !== undefined && patterns.length > 0) {\n\t\t\t\tconst scopedModels = await resolveModelScope(patterns, this.session.modelRegistry);\n\t\t\t\tcurrentEnabledIds = scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);\n\t\t\t}\n\t\t}\n\n\t\t// Helper to update session's scoped models (session-only, no persist)\n\t\tconst updateSessionModels = async (enabledIds: string[] | null) => {\n\t\t\tcurrentEnabledIds = enabledIds === null ? null : [...enabledIds];\n\t\t\tif (enabledIds && enabledIds.length > 0 && enabledIds.length < allModels.length) {\n\t\t\t\tconst newScopedModels = await resolveModelScope(enabledIds, this.session.modelRegistry);\n\t\t\t\tthis.session.setScopedModels(\n\t\t\t\t\tnewScopedModels.map((sm) => ({\n\t\t\t\t\t\tmodel: sm.model,\n\t\t\t\t\t\tthinkingLevel: sm.thinkingLevel,\n\t\t\t\t\t})),\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\t// All enabled or none enabled = no filter\n\t\t\t\tthis.session.setScopedModels([]);\n\t\t\t}\n\t\t\tawait this.updateAvailableProviderCount();\n\t\t\tthis.deps.ui.requestRender();\n\t\t};\n\n\t\tthis.deps.showSelector((done) => {\n\t\t\tconst selector = new ScopedModelsSelectorComponent(\n\t\t\t\t{\n\t\t\t\t\tallModels,\n\t\t\t\t\tenabledModelIds: currentEnabledIds,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tonChange: async (enabledIds) => {\n\t\t\t\t\t\tawait updateSessionModels(enabledIds);\n\t\t\t\t\t},\n\t\t\t\t\tonPersist: (enabledIds) => {\n\t\t\t\t\t\t// Persist to settings\n\t\t\t\t\t\tconst newPatterns =\n\t\t\t\t\t\t\tenabledIds === null || enabledIds.length === allModels.length\n\t\t\t\t\t\t\t\t? undefined // All enabled = clear filter\n\t\t\t\t\t\t\t\t: enabledIds;\n\t\t\t\t\t\tthis.session.settingsManager.setEnabledModels(newPatterns ? [...newPatterns] : undefined);\n\t\t\t\t\t\tthis.deps.showStatus(\"Model selection saved to settings\");\n\t\t\t\t\t},\n\t\t\t\t\tonCancel: () => {\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.deps.ui.requestRender();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector };\n\t\t});\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"model-controller.js","sourceRoot":"","sources":["../../../src/modes/interactive/model-controller.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Model selection for the interactive mode (/model, /models, cycle keys).\n *\n * Owns the single-model picker, the scoped-models (enable set) picker, model\n * cycling, exact-match lookup for slash-command arguments, the footer's\n * available-provider count, and the Anthropic subscription-auth warning.\n * Extracted from interactive-mode.ts behind a narrow ModelControllerDeps\n * interface.\n */\n\nimport type { Model } from \"@kolisachint/hoocode-ai\";\nimport type { Component, TUI } from \"@kolisachint/hoocode-tui\";\nimport type { AgentSession } from \"../../core/agent-session.js\";\nimport type { AppKeybinding } from \"../../core/keybindings.js\";\nimport { findExactModelReferenceMatch, resolveModelScope } from \"../../core/model-resolver.js\";\nimport { ModelSelectorComponent } from \"./components/model-selector.js\";\nimport { ScopedModelsSelectorComponent } from \"./components/scoped-models-selector.js\";\n\nconst ANTHROPIC_SUBSCRIPTION_AUTH_TITLE = \"Anthropic subscription\";\nconst ANTHROPIC_SUBSCRIPTION_AUTH_BODY = [\n\t\"Billed per token as extra usage, not against plan limits.\",\n\t\"Turn off in /settings → Anthropic extra usage.\",\n];\n\nfunction isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean {\n\treturn typeof apiKey === \"string\" && apiKey.startsWith(\"sk-ant-oat\");\n}\n\n/** The slice of the interactive mode the model flows need. */\nexport interface ModelControllerDeps {\n\tui: TUI;\n\t/** The active session (read at call time; the session can be swapped). */\n\tget session(): AgentSession;\n\tshowSelector(create: (done: () => void) => { component: Component; focus: Component }): void;\n\tshowStatus(message: string): void;\n\t/** Report a dial step, naming its reverse key the first time. */\n\tshowDialStep(backward: AppKeybinding, message: string): void;\n\tshowError(errorMessage: string): void;\n\tshowWarning(warningMessage: string): void;\n\t/** A filled notice, for warnings that cost money if ignored. */\n\tshowNotice(title: string, body: string[]): void;\n\tupdateEditorBorderColor(): void;\n\tinvalidateFooter(): void;\n\tsetAvailableProviderCount(count: number): void;\n}\n\nexport class ModelController {\n\tprivate anthropicSubscriptionWarningShown = false;\n\n\tconstructor(private readonly deps: ModelControllerDeps) {}\n\n\tprivate get session(): AgentSession {\n\t\treturn this.deps.session;\n\t}\n\n\tasync findExactModelMatch(searchTerm: string): Promise<Model<any> | undefined> {\n\t\tconst models = await this.getModelCandidates();\n\t\treturn findExactModelReferenceMatch(searchTerm, models);\n\t}\n\n\tprivate async getModelCandidates(): Promise<Model<any>[]> {\n\t\tif (this.session.scopedModels.length > 0) {\n\t\t\treturn this.session.scopedModels.map((scoped) => scoped.model);\n\t\t}\n\n\t\tthis.session.modelRegistry.refresh();\n\t\ttry {\n\t\t\treturn await this.session.modelRegistry.getAvailable();\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t}\n\n\t/** Update the footer's available provider count from current model candidates */\n\tasync updateAvailableProviderCount(): Promise<void> {\n\t\tconst models = await this.getModelCandidates();\n\t\tconst uniqueProviders = new Set(models.map((m) => m.provider));\n\t\tthis.deps.setAvailableProviderCount(uniqueProviders.size);\n\t}\n\n\t/** Whether the model bills through Anthropic subscription auth (extra usage). */\n\tprivate async usesAnthropicSubscriptionAuth(model: Model<any> | undefined): Promise<boolean> {\n\t\tif (this.session.settingsManager.getWarnings().anthropicExtraUsage === false) {\n\t\t\treturn false;\n\t\t}\n\t\tif (!model || model.provider !== \"anthropic\") {\n\t\t\treturn false;\n\t\t}\n\n\t\tconst storedCredential = this.session.modelRegistry.authStorage.get(\"anthropic\");\n\t\tif (storedCredential?.type === \"oauth\") {\n\t\t\treturn true;\n\t\t}\n\n\t\ttry {\n\t\t\tconst apiKey = await this.session.modelRegistry.getApiKeyForProvider(model.provider);\n\t\t\treturn isAnthropicSubscriptionAuthKey(apiKey);\n\t\t} catch {\n\t\t\t// Ignore auth lookup failures for warning-only checks.\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/**\n\t * Resolving the auth type can hit the keychain, so every caller fires this\n\t * without awaiting it; the once-per-session latch is what keeps the notice\n\t * from repeating, not the call site.\n\t */\n\tasync maybeWarnAboutAnthropicSubscriptionAuth(model: Model<any> | undefined = this.session.model): Promise<void> {\n\t\tif (this.anthropicSubscriptionWarningShown) {\n\t\t\treturn;\n\t\t}\n\t\tif (!(await this.usesAnthropicSubscriptionAuth(model))) {\n\t\t\treturn;\n\t\t}\n\t\tthis.anthropicSubscriptionWarningShown = true;\n\t\tthis.deps.showNotice(ANTHROPIC_SUBSCRIPTION_AUTH_TITLE, ANTHROPIC_SUBSCRIPTION_AUTH_BODY);\n\t}\n\n\tasync cycleModel(direction: \"forward\" | \"backward\"): Promise<void> {\n\t\ttry {\n\t\t\tconst result = await this.session.cycleModel(direction);\n\t\t\tif (result === undefined) {\n\t\t\t\tconst msg = this.session.scopedModels.length > 0 ? \"Only one model in scope\" : \"Only one model available\";\n\t\t\t\tthis.deps.showStatus(msg);\n\t\t\t} else {\n\t\t\t\tthis.deps.invalidateFooter();\n\t\t\t\tthis.deps.updateEditorBorderColor();\n\t\t\t\tconst thinkingStr =\n\t\t\t\t\tresult.model.reasoning && result.thinkingLevel !== \"off\" ? ` (thinking: ${result.thinkingLevel})` : \"\";\n\t\t\t\tthis.deps.showDialStep(\n\t\t\t\t\tdirection === \"forward\" ? \"app.model.cycleBackward\" : \"app.model.cycleForward\",\n\t\t\t\t\t`Switched to ${result.model.name || result.model.id}${thinkingStr}`,\n\t\t\t\t);\n\t\t\t\tvoid this.maybeWarnAboutAnthropicSubscriptionAuth(result.model);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tthis.deps.showError(error instanceof Error ? error.message : String(error));\n\t\t}\n\t}\n\n\tshowModelSelector(initialSearchInput?: string): void {\n\t\tthis.deps.showSelector((done) => {\n\t\t\tconst selector = new ModelSelectorComponent(\n\t\t\t\tthis.deps.ui,\n\t\t\t\tthis.session.model,\n\t\t\t\tthis.session.settingsManager,\n\t\t\t\tthis.session.modelRegistry,\n\t\t\t\tthis.session.scopedModels,\n\t\t\t\tasync (model) => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait this.session.setModel(model);\n\t\t\t\t\t\tthis.deps.invalidateFooter();\n\t\t\t\t\t\tthis.deps.updateEditorBorderColor();\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.deps.showStatus(`Model: ${model.id}`);\n\t\t\t\t\t\tvoid this.maybeWarnAboutAnthropicSubscriptionAuth(model);\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.deps.showError(error instanceof Error ? error.message : String(error));\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\tdone();\n\t\t\t\t\tthis.deps.ui.requestRender();\n\t\t\t\t},\n\t\t\t\tinitialSearchInput,\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector };\n\t\t});\n\t}\n\n\tasync showModelsSelector(): Promise<void> {\n\t\t// Get all available models\n\t\tthis.session.modelRegistry.refresh();\n\t\tconst allModels = this.session.modelRegistry.getAvailable();\n\n\t\tif (allModels.length === 0) {\n\t\t\tthis.deps.showStatus(\"No models available\");\n\t\t\treturn;\n\t\t}\n\n\t\t// Check if session has scoped models (from previous session-only changes or CLI --models)\n\t\tconst sessionScopedModels = this.session.scopedModels;\n\t\tconst hasSessionScope = sessionScopedModels.length > 0;\n\n\t\t// Build enabled model IDs from session state or settings\n\t\tlet currentEnabledIds: string[] | null = null;\n\n\t\tif (hasSessionScope) {\n\t\t\t// Use current session's scoped models\n\t\t\tcurrentEnabledIds = sessionScopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);\n\t\t} else {\n\t\t\t// Fall back to settings\n\t\t\tconst patterns = this.session.settingsManager.getEnabledModels();\n\t\t\tif (patterns !== undefined && patterns.length > 0) {\n\t\t\t\tconst scopedModels = await resolveModelScope(patterns, this.session.modelRegistry);\n\t\t\t\tcurrentEnabledIds = scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);\n\t\t\t}\n\t\t}\n\n\t\t// Helper to update session's scoped models (session-only, no persist)\n\t\tconst updateSessionModels = async (enabledIds: string[] | null) => {\n\t\t\tcurrentEnabledIds = enabledIds === null ? null : [...enabledIds];\n\t\t\tif (enabledIds && enabledIds.length > 0 && enabledIds.length < allModels.length) {\n\t\t\t\tconst newScopedModels = await resolveModelScope(enabledIds, this.session.modelRegistry);\n\t\t\t\tthis.session.setScopedModels(\n\t\t\t\t\tnewScopedModels.map((sm) => ({\n\t\t\t\t\t\tmodel: sm.model,\n\t\t\t\t\t\tthinkingLevel: sm.thinkingLevel,\n\t\t\t\t\t})),\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\t// All enabled or none enabled = no filter\n\t\t\t\tthis.session.setScopedModels([]);\n\t\t\t}\n\t\t\tawait this.updateAvailableProviderCount();\n\t\t\tthis.deps.ui.requestRender();\n\t\t};\n\n\t\tthis.deps.showSelector((done) => {\n\t\t\tconst selector = new ScopedModelsSelectorComponent(\n\t\t\t\t{\n\t\t\t\t\tallModels,\n\t\t\t\t\tenabledModelIds: currentEnabledIds,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tonChange: async (enabledIds) => {\n\t\t\t\t\t\tawait updateSessionModels(enabledIds);\n\t\t\t\t\t},\n\t\t\t\t\tonPersist: (enabledIds) => {\n\t\t\t\t\t\t// Persist to settings\n\t\t\t\t\t\tconst newPatterns =\n\t\t\t\t\t\t\tenabledIds === null || enabledIds.length === allModels.length\n\t\t\t\t\t\t\t\t? undefined // All enabled = clear filter\n\t\t\t\t\t\t\t\t: enabledIds;\n\t\t\t\t\t\tthis.session.settingsManager.setEnabledModels(newPatterns ? [...newPatterns] : undefined);\n\t\t\t\t\t\tthis.deps.showStatus(\"Model selection saved to settings\");\n\t\t\t\t\t},\n\t\t\t\t\tonCancel: () => {\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.deps.ui.requestRender();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector };\n\t\t});\n\t}\n}\n"]}
|
package/docs/keybindings.md
CHANGED
|
@@ -4,10 +4,98 @@ All keyboard shortcuts can be customized via `~/.hoocode/keybindings.json`. Each
|
|
|
4
4
|
|
|
5
5
|
The config file uses the same namespaced keybinding ids that hoocode uses internally and that extension authors use in `keyHint()` and injected `keybindings` managers.
|
|
6
6
|
|
|
7
|
-
Older configs using pre-namespaced ids such as `cursorUp` or `expandTools` are migrated automatically to the namespaced ids on startup.
|
|
7
|
+
Older configs using pre-namespaced ids such as `cursorUp` or `expandTools` are migrated automatically to the namespaced ids on startup, as are `app.thinking.cycle` and `app.mode.cycle`, which became `…cycleForward` when each gained a backward half.
|
|
8
8
|
|
|
9
9
|
After editing `keybindings.json`, run `/reload` in hoocode to apply the changes without restarting the session.
|
|
10
10
|
|
|
11
|
+
## How this is grouped
|
|
12
|
+
|
|
13
|
+
There are around sixty bindings. Nobody holds sixty of anything, and the obvious
|
|
14
|
+
answer — sort them by mechanism, so everything that cycles sits together — makes
|
|
15
|
+
a list that is tidy on the page and useless at the keyboard. "It cycles" is a
|
|
16
|
+
fact about the widget. It is not what you are thinking when you reach for a key.
|
|
17
|
+
|
|
18
|
+
So the grouping is by **intention**: the five things you are ever doing here, in
|
|
19
|
+
the order the loop runs.
|
|
20
|
+
|
|
21
|
+
| Group | What it is | Keys |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| **Compose** | the message in your hands | `Alt+E` `Alt+R` `Ctrl+V` `Alt+Enter` `Alt+↑` |
|
|
24
|
+
| **Steer** | what the agent is before it runs | `Alt+A` `Alt+M` `Alt+T` |
|
|
25
|
+
| **Read** | what you see of what it did | `Alt+O` `Alt+L` `Ctrl+O` `Ctrl+T` |
|
|
26
|
+
| **Go** | sessions and places | `Alt+H` `Alt+W` `Alt+C` `Alt+S` `Alt+K` |
|
|
27
|
+
| **Flow** | getting out, getting back | `Esc` `Ctrl+C` `Ctrl+D` `Ctrl+Z` |
|
|
28
|
+
|
|
29
|
+
Five groups, none bigger than five, which is about the size a person can hold at
|
|
30
|
+
once. Two of them cost nothing to learn: **Flow** is the set every terminal
|
|
31
|
+
program already taught you, and the pickers print their own keys on their own
|
|
32
|
+
hint lines — read, never remembered. That leaves three groups to genuinely know.
|
|
33
|
+
|
|
34
|
+
The declaration order in `core/keybindings.ts` is this grouping, and it is not
|
|
35
|
+
cosmetic: your `keybindings.json` is written in the same order, so the file you
|
|
36
|
+
open to rebind something is grouped the way this page is.
|
|
37
|
+
|
|
38
|
+
## What the chord tells you
|
|
39
|
+
|
|
40
|
+
The modifier says what kind of thing will happen; the letter says to what.
|
|
41
|
+
|
|
42
|
+
**`Alt`+letter sets a value.** Nothing takes the screen, nothing loses focus, you
|
|
43
|
+
keep typing. Six of these are dials — an ordered set of stops with the current
|
|
44
|
+
one painted where you can see it — and `Shift+Alt`+letter always steps back:
|
|
45
|
+
|
|
46
|
+
| Dial | Where you see it | Forward | Back | Steps through |
|
|
47
|
+
|---|---|---|---|---|
|
|
48
|
+
| **A**gent mode | footer, bold, top left | `Alt+A` | `Shift+Alt+A` | ask → plan → build → debug |
|
|
49
|
+
| **M**odel | footer, bottom right | `Alt+M` | `Shift+Alt+M` | your enabled models |
|
|
50
|
+
| **T**hinking level | footer, bottom right | `Alt+T` | `Shift+Alt+T` | off → … → high |
|
|
51
|
+
| Tool **o**utput | footer, top right | `Alt+O` | `Shift+Alt+O` | radar → peek → full |
|
|
52
|
+
| Task **l**ist | task panel header | `Alt+L` | `Shift+Alt+L` | tasks → subagents → teams |
|
|
53
|
+
| Session **c**olor | the session chip | `Alt+C` | `Shift+Alt+C` | six chip slots |
|
|
54
|
+
|
|
55
|
+
Reversibility is the point, not symmetry. A control you can undo invites you to
|
|
56
|
+
try it; a one-way control makes you stop and think first, which is the wrong tax
|
|
57
|
+
on a key you press all day. The first time you step each dial in a session, the
|
|
58
|
+
status line names the key that steps it back — once, at the moment you are
|
|
59
|
+
primed to learn it, and never again after that.
|
|
60
|
+
|
|
61
|
+
**`Ctrl`+letter acts on what is drawn right now**, and shares its letter with the
|
|
62
|
+
`Alt` key for the same subject:
|
|
63
|
+
|
|
64
|
+
- `Alt+O` sets how much tool output there ever is and saves where it lands;
|
|
65
|
+
`Ctrl+O` jumps to all of it and back, leaving the dial where it was.
|
|
66
|
+
- `Alt+T` sets how much thinking there ever is; `Ctrl+T` shows or hides the
|
|
67
|
+
thinking you have.
|
|
68
|
+
|
|
69
|
+
Two subjects, two letters, four keys — half what four unrelated chords cost.
|
|
70
|
+
|
|
71
|
+
**A slash command picks a stop outright.** `/mode`, `/model`, `/color`, `/tree`.
|
|
72
|
+
The key steps, the command chooses; that is why `app.model.select` and
|
|
73
|
+
`app.session.tree` ship unbound, each having given its letter to a dial.
|
|
74
|
+
|
|
75
|
+
**Inside a picker, `Ctrl` belongs to the query you are typing** — `Ctrl+A` is
|
|
76
|
+
start of line, `Ctrl+U` kills the line, `Ctrl+W` kills a word. A picker's verbs
|
|
77
|
+
are therefore all on `Alt`, mnemonic to that picker. The picker captures keys
|
|
78
|
+
while it is open, so a verb may reuse a letter the global set already has.
|
|
79
|
+
|
|
80
|
+
One letter in the whole set names nothing: `Alt+N`, the team roster. It survives
|
|
81
|
+
because the task panel prints it in its own header — read off the screen rather
|
|
82
|
+
than remembered, which is the fallback for anything that cannot earn a mnemonic.
|
|
83
|
+
|
|
84
|
+
The thinking level is the one dial with a second key: `Shift+Tab` still steps it.
|
|
85
|
+
It is the only dial with no slash command, so on a terminal that does not send
|
|
86
|
+
`Alt` that is the way to it.
|
|
87
|
+
|
|
88
|
+
Two consequences worth knowing before rebinding:Two consequences worth knowing before rebinding:
|
|
89
|
+
|
|
90
|
+
- No default takes a key the editor or the terminal already owns —
|
|
91
|
+
`Ctrl+A/E/B/F/K/U/W/Y/D/L/R/G`, `Ctrl+S` (XOFF), `Ctrl+M` (enter), `Ctrl+I`
|
|
92
|
+
(tab).
|
|
93
|
+
- No verb sits on a bare `Shift+<letter>`. Outside the Kitty keyboard protocol
|
|
94
|
+
that arrives as the plain uppercase letter, which is indistinguishable from
|
|
95
|
+
typing in any scope that has a query line.
|
|
96
|
+
|
|
97
|
+
`test/keybinding-layout.test.ts` holds all of this.
|
|
98
|
+
|
|
11
99
|
## Key Format
|
|
12
100
|
|
|
13
101
|
`modifier+key` where modifiers are `ctrl`, `shift`, `alt` (combinable) and keys are:
|
|
@@ -24,14 +112,102 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1
|
|
|
24
112
|
> protocol, `Option` composes characters instead of sending `Alt` — `Option+M`
|
|
25
113
|
> types `µ`. Some Linux terminals claim `Alt+<letter>` for menu mnemonics. See
|
|
26
114
|
> [terminal-setup.md](terminal-setup.md#alt-keys) for the per-terminal setting.
|
|
115
|
+
>
|
|
116
|
+
> Interrupt, clear, exit, expand and submit are deliberately never on `Alt`, so
|
|
117
|
+
> a terminal that eats `Option` is still recoverable.
|
|
27
118
|
|
|
28
119
|
## All Actions
|
|
29
120
|
|
|
30
|
-
|
|
121
|
+
Listed in the five families, in the order `core/keybindings.ts` declares them —
|
|
122
|
+
which is also the order your `keybindings.json` is written in.
|
|
123
|
+
|
|
124
|
+
### Compose — the message in your hands
|
|
125
|
+
|
|
126
|
+
| Keybinding id | Default | Description |
|
|
127
|
+
|--------|---------|-------------|
|
|
128
|
+
| `tui.input.submit` | `enter` | Send message |
|
|
129
|
+
| `tui.input.newLine` | `shift+enter` | Insert new line |
|
|
130
|
+
| `tui.input.tab` | `tab` | Path completion / accept autocomplete |
|
|
131
|
+
| `app.editor.external` | `alt+e` | Edit the message in `$VISUAL` / `$EDITOR` |
|
|
132
|
+
| `app.input.voiceTranscribe` | `alt+r` | Speak instead of type |
|
|
133
|
+
| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows) | Paste image from clipboard |
|
|
134
|
+
| `app.message.followUp` | `alt+enter` | Queue a follow-up while the agent works |
|
|
135
|
+
| `app.message.dequeue` | `alt+up` | Bring every queued message back to the editor |
|
|
136
|
+
|
|
137
|
+
### Steer — what the agent is before it runs
|
|
138
|
+
|
|
139
|
+
The only three that change what happens next, and the only three that cost
|
|
140
|
+
anything. The footer shows all three.
|
|
141
|
+
|
|
142
|
+
| Keybinding id | Default | Description |
|
|
143
|
+
|--------|---------|-------------|
|
|
144
|
+
| `app.mode.cycleForward` | `alt+a` | Step agent mode: ask → plan → build → debug |
|
|
145
|
+
| `app.mode.cycleBackward` | `shift+alt+a` | Step agent mode backward |
|
|
146
|
+
| `app.model.cycleForward` | `alt+m` | Step to the next model |
|
|
147
|
+
| `app.model.cycleBackward` | `shift+alt+m` | Step to the previous model |
|
|
148
|
+
| `app.model.select` | *(none)* | Open the model selector (`/model`) |
|
|
149
|
+
| `app.thinking.cycleForward` | `alt+t`, `shift+tab` | Step thinking level: off → … → high |
|
|
150
|
+
| `app.thinking.cycleBackward` | `shift+alt+t` | Step thinking level backward |
|
|
151
|
+
|
|
152
|
+
### Read — what you see of what it did
|
|
153
|
+
|
|
154
|
+
Free and reversible, every one: nothing here touches the work, only the window
|
|
155
|
+
onto it.
|
|
156
|
+
|
|
157
|
+
| Keybinding id | Default | Description |
|
|
158
|
+
|--------|---------|-------------|
|
|
159
|
+
| `app.view.cycleForward` | `alt+o` | Step tool output: radar → peek → full |
|
|
160
|
+
| `app.view.cycleBackward` | `shift+alt+o` | Step tool output backward |
|
|
161
|
+
| `app.tools.expand` | `ctrl+o` | Jump to the full view and back, without moving the dial |
|
|
162
|
+
| `app.thinking.toggle` | `ctrl+t` | Show or hide thinking blocks |
|
|
163
|
+
| `app.tasks.cycleForward` | `alt+l` | Step task panel view: tasks → subagents → teams |
|
|
164
|
+
| `app.tasks.cycleBackward` | `shift+alt+l` | Step task panel view backward |
|
|
165
|
+
| `app.team.focus` | `alt+n` | Focus the team roster (`--team`) |
|
|
166
|
+
|
|
167
|
+
### Go — sessions and places
|
|
168
|
+
|
|
169
|
+
Each takes the screen and hands it back on `escape`, and each has a slash command
|
|
170
|
+
that does the same thing.
|
|
31
171
|
|
|
32
172
|
| Keybinding id | Default | Description |
|
|
33
173
|
|--------|---------|-------------|
|
|
34
|
-
| `
|
|
174
|
+
| `app.session.resume` | `alt+h` | Resume a session from history (`/resume`) |
|
|
175
|
+
| `app.session.tree` | *(none)* | Open the session tree (`/tree`) |
|
|
176
|
+
| `app.session.new` | *(none)* | Start a new session (`/new`) |
|
|
177
|
+
| `app.session.fork` | *(none)* | Fork the current session (`/fork`) |
|
|
178
|
+
| `app.session.changeDirectory` | `alt+w` | Change working directory (`/cd`) |
|
|
179
|
+
| `app.session.color.cycleForward` | `alt+c` | Step the session chip's color |
|
|
180
|
+
| `app.session.color.cycleBackward` | `shift+alt+c` | Step the session chip's color backward |
|
|
181
|
+
| `app.settings.open` | `alt+s` | Open settings (`/settings`) |
|
|
182
|
+
| `app.hotkeys.open` | `alt+k` | Show the shortcut list (`/hotkeys`) |
|
|
183
|
+
|
|
184
|
+
`app.session.new` and `app.session.fork` ship unbound because one replaces the
|
|
185
|
+
transcript and the other needs a message picked out of it, so neither wants to be
|
|
186
|
+
one stray chord away. `app.model.select` and `app.session.tree` are unbound for a
|
|
187
|
+
different reason — each gave its letter to the dial that shares it. All four are
|
|
188
|
+
bindable by hand.
|
|
189
|
+
|
|
190
|
+
### Flow — getting out, getting back
|
|
191
|
+
|
|
192
|
+
Every terminal program already bound these, so they cost nothing to learn and
|
|
193
|
+
never move: whatever else is misconfigured, you can still stop the agent, clear
|
|
194
|
+
the line, and leave.
|
|
195
|
+
|
|
196
|
+
| Keybinding id | Default | Description |
|
|
197
|
+
|--------|---------|-------------|
|
|
198
|
+
| `app.interrupt` | `escape` | Cancel autocomplete / abort streaming |
|
|
199
|
+
| `app.clear` | `ctrl+c` | Clear editor (press twice to exit) |
|
|
200
|
+
| `app.exit` | `ctrl+d` | Exit when the editor is empty |
|
|
201
|
+
| `app.suspend` | `ctrl+z` (none on Windows) | Suspend to background |
|
|
202
|
+
|
|
203
|
+
### The editor
|
|
204
|
+
|
|
205
|
+
Standard readline/emacs bindings. Reference, not something to memorise — which
|
|
206
|
+
is why they are not one of the five groups.
|
|
207
|
+
|
|
208
|
+
| Keybinding id | Default | Description |
|
|
209
|
+
|--------|---------|-------------|
|
|
210
|
+
| `tui.editor.cursorUp` | `up` | Move cursor up / browse history when empty |
|
|
35
211
|
| `tui.editor.cursorDown` | `down` | Move cursor down |
|
|
36
212
|
| `tui.editor.cursorLeft` | `left`, `ctrl+b` | Move cursor left |
|
|
37
213
|
| `tui.editor.cursorRight` | `right`, `ctrl+f` | Move cursor right |
|
|
@@ -43,39 +219,32 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1
|
|
|
43
219
|
| `tui.editor.jumpBackward` | `ctrl+alt+]` | Jump backward to character |
|
|
44
220
|
| `tui.editor.pageUp` | `pageUp` | Scroll up by page |
|
|
45
221
|
| `tui.editor.pageDown` | `pageDown` | Scroll down by page |
|
|
46
|
-
|
|
47
|
-
### TUI Editor Deletion
|
|
48
|
-
|
|
49
|
-
| Keybinding id | Default | Description |
|
|
50
|
-
|--------|---------|-------------|
|
|
51
222
|
| `tui.editor.deleteCharBackward` | `backspace` | Delete character backward |
|
|
52
223
|
| `tui.editor.deleteCharForward` | `delete`, `ctrl+d` | Delete character forward |
|
|
53
224
|
| `tui.editor.deleteWordBackward` | `ctrl+w`, `alt+backspace` | Delete word backward |
|
|
54
225
|
| `tui.editor.deleteWordForward` | `alt+d`, `alt+delete` | Delete word forward |
|
|
55
226
|
| `tui.editor.deleteToLineStart` | `ctrl+u` | Delete to line start |
|
|
56
227
|
| `tui.editor.deleteToLineEnd` | `ctrl+k` | Delete to line end |
|
|
228
|
+
| `tui.editor.yank` | `ctrl+y` | Paste the most recently deleted text |
|
|
229
|
+
| `tui.editor.yankPop` | `alt+y` | Cycle through deleted text after a yank |
|
|
230
|
+
| `tui.editor.undo` | `ctrl+-` | Undo last edit |
|
|
231
|
+
| `tui.input.copy` | `ctrl+c` | Copy the selection |
|
|
57
232
|
|
|
58
|
-
|
|
233
|
+
`ctrl+d` and `ctrl+c` each carry two ids. They are one key with a
|
|
234
|
+
state-dependent meaning, not a conflict: `ctrl+d` exits only on an empty
|
|
235
|
+
editor and deletes a character otherwise, and `ctrl+c` copies when there is a
|
|
236
|
+
selection and clears the editor when there is not.
|
|
59
237
|
|
|
60
|
-
|
|
61
|
-
|--------|---------|-------------|
|
|
62
|
-
| `tui.input.newLine` | `shift+enter` | Insert new line |
|
|
63
|
-
| `tui.input.submit` | `enter` | Submit input |
|
|
64
|
-
| `tui.input.tab` | `tab` | Tab / autocomplete |
|
|
65
|
-
|
|
66
|
-
### TUI Kill Ring
|
|
238
|
+
### Overlays — live only while their surface is open
|
|
67
239
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
| `tui.editor.yankPop` | `alt+y` | Cycle through deleted text after yank |
|
|
72
|
-
| `tui.editor.undo` | `ctrl+-` | Undo last edit |
|
|
240
|
+
Never a memory burden: each surface prints its own keys on its own hint line, so
|
|
241
|
+
these are recognised, not recalled. They may reuse a letter the global set
|
|
242
|
+
already has, because the surface captures keys while it is up.
|
|
73
243
|
|
|
74
|
-
|
|
244
|
+
#### Lists and pickers, everywhere
|
|
75
245
|
|
|
76
246
|
| Keybinding id | Default | Description |
|
|
77
247
|
|--------|---------|-------------|
|
|
78
|
-
| `tui.input.copy` | `ctrl+c` | Copy selection |
|
|
79
248
|
| `tui.select.up` | `up` | Move selection up |
|
|
80
249
|
| `tui.select.down` | `down` | Move selection down |
|
|
81
250
|
| `tui.select.pageUp` | `pageUp` | Page up in list |
|
|
@@ -83,82 +252,73 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1
|
|
|
83
252
|
| `tui.select.confirm` | `enter` | Confirm selection |
|
|
84
253
|
| `tui.select.cancel` | `escape`, `ctrl+c` | Cancel selection |
|
|
85
254
|
|
|
86
|
-
|
|
255
|
+
#### Session picker (`/resume`)
|
|
87
256
|
|
|
88
257
|
| Keybinding id | Default | Description |
|
|
89
258
|
|--------|---------|-------------|
|
|
90
|
-
| `app.
|
|
91
|
-
| `app.
|
|
92
|
-
| `app.
|
|
93
|
-
| `app.
|
|
94
|
-
| `app.
|
|
95
|
-
| `app.
|
|
259
|
+
| `app.session.togglePath` | `alt+p` | Toggle path display |
|
|
260
|
+
| `app.session.toggleSort` | `alt+o` | Toggle sort order |
|
|
261
|
+
| `app.session.toggleNamedFilter` | `alt+n` | Toggle the named-only filter |
|
|
262
|
+
| `app.session.rename` | `alt+r` | Rename the selected session |
|
|
263
|
+
| `app.session.delete` | `alt+x` | Delete the selected session |
|
|
264
|
+
| `app.session.deleteNoninvasive` | `ctrl+backspace` | Delete, but only while the query is empty |
|
|
96
265
|
|
|
97
|
-
|
|
266
|
+
#### Session tree (`/tree`)
|
|
98
267
|
|
|
99
268
|
| Keybinding id | Default | Description |
|
|
100
269
|
|--------|---------|-------------|
|
|
101
|
-
| `app.
|
|
102
|
-
| `app.
|
|
103
|
-
| `app.
|
|
104
|
-
| `app.
|
|
105
|
-
| `app.
|
|
106
|
-
| `app.
|
|
107
|
-
| `app.
|
|
108
|
-
| `app.
|
|
109
|
-
| `app.
|
|
110
|
-
| `app.
|
|
111
|
-
| `app.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
270
|
+
| `app.tree.foldOrUp` | `ctrl+left`, `alt+left` | Fold the branch segment, or jump to the previous one |
|
|
271
|
+
| `app.tree.unfoldOrDown` | `ctrl+right`, `alt+right` | Unfold the branch segment, or jump to the next one |
|
|
272
|
+
| `app.tree.editLabel` | `alt+l` | Edit the label on the selected node |
|
|
273
|
+
| `app.tree.toggleLabelTimestamp` | `alt+t` | Show or hide label timestamps |
|
|
274
|
+
| `app.tree.filter.default` | `alt+1` | Filter: default view |
|
|
275
|
+
| `app.tree.filter.noTools` | `alt+2` | Filter: hide tool results |
|
|
276
|
+
| `app.tree.filter.userOnly` | `alt+3` | Filter: user messages only |
|
|
277
|
+
| `app.tree.filter.labeledOnly` | `alt+4` | Filter: labeled entries only |
|
|
278
|
+
| `app.tree.filter.all` | `alt+5` | Filter: show everything |
|
|
279
|
+
| `app.tree.filter.cycleForward` | `alt+c` | Cycle the filter forward |
|
|
280
|
+
| `app.tree.filter.cycleBackward` | `shift+alt+c` | Cycle the filter backward |
|
|
281
|
+
|
|
282
|
+
The five lenses are numbered rather than lettered because they are an ordered
|
|
283
|
+
set: `alt+1`-`alt+5` needs no mnemonic. Everything else the tree types goes
|
|
284
|
+
into its search query.
|
|
285
|
+
|
|
286
|
+
#### Scoped models picker (`/models`)
|
|
115
287
|
|
|
116
288
|
| Keybinding id | Default | Description |
|
|
117
289
|
|--------|---------|-------------|
|
|
118
|
-
| `app.
|
|
119
|
-
| `app.
|
|
120
|
-
| `app.
|
|
121
|
-
| `app.
|
|
122
|
-
| `app.
|
|
290
|
+
| `app.models.save` | `alt+s` | Save the selection to settings |
|
|
291
|
+
| `app.models.enableAll` | `alt+a` | Enable all models (or all matching the query) |
|
|
292
|
+
| `app.models.clearAll` | `alt+x` | Clear all models (or all matching the query) |
|
|
293
|
+
| `app.models.toggleProvider` | `alt+g` | Toggle every model for the current provider |
|
|
294
|
+
| `app.models.reorderUp` | `alt+up` | Move the selected model up in the cycle order |
|
|
295
|
+
| `app.models.reorderDown` | `alt+down` | Move the selected model down in the cycle order |
|
|
123
296
|
|
|
124
|
-
|
|
297
|
+
`alt+g` ("group") rather than the obvious `alt+p`: without the Kitty protocol
|
|
298
|
+
`alt+p` arrives as `ESC-p`, which the parser also reads as `alt+up` — this
|
|
299
|
+
picker's reorder key.
|
|
125
300
|
|
|
126
|
-
|
|
127
|
-
|--------|---------|-------------|
|
|
128
|
-
| `app.tools.expand` | `ctrl+o` | Jump to the full tool-output view, and back to the stop you came from |
|
|
129
|
-
| `app.view.cycleForward` | `alt+o` | Cycle the tool output view (radar → peek → full) |
|
|
130
|
-
| `app.view.cycleBackward` | `shift+alt+o` | Cycle the tool output view backward |
|
|
131
|
-
| `app.message.followUp` | `alt+enter` | Queue follow-up message |
|
|
132
|
-
| `app.message.dequeue` | `alt+up` | Restore queued messages to editor |
|
|
301
|
+
#### Team roster (`--team`)
|
|
133
302
|
|
|
134
|
-
|
|
303
|
+
Live only while the task panel holds focus, which is why they are plain
|
|
304
|
+
letters: you are never typing there.
|
|
135
305
|
|
|
136
306
|
| Keybinding id | Default | Description |
|
|
137
307
|
|--------|---------|-------------|
|
|
138
|
-
| `app.
|
|
139
|
-
| `app.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
| `app.tree.filter.userOnly` | `ctrl+u` | Toggle tree filter that shows only user messages |
|
|
145
|
-
| `app.tree.filter.labeledOnly` | `ctrl+l` | Toggle tree filter that shows only labeled entries |
|
|
146
|
-
| `app.tree.filter.all` | `ctrl+a` | Toggle tree filter that shows all entries |
|
|
147
|
-
| `app.tree.filter.cycleForward` | `ctrl+o` | Cycle tree filter forward |
|
|
148
|
-
| `app.tree.filter.cycleBackward` | `shift+ctrl+o` | Cycle tree filter backward |
|
|
149
|
-
|
|
150
|
-
### Scoped Models Selector
|
|
151
|
-
|
|
152
|
-
Used inside the scoped models selector (opened via `/scoped-models`).
|
|
308
|
+
| `app.team.nudge` | `n` | Nudge the selected role |
|
|
309
|
+
| `app.team.attach` | `a` | Attach to the selected role |
|
|
310
|
+
|
|
311
|
+
#### Options pane
|
|
312
|
+
|
|
313
|
+
The pane the agent raises to ask you a question.
|
|
153
314
|
|
|
154
315
|
| Keybinding id | Default | Description |
|
|
155
316
|
|--------|---------|-------------|
|
|
156
|
-
| `app.
|
|
157
|
-
| `app.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
| `app.models.reorderDown` | `alt+down` | Move the selected model down in the cycle order |
|
|
317
|
+
| `app.options.next` | `right` | Confirm the highlighted answer and advance |
|
|
318
|
+
| `app.options.back` | `left` | Go back to the previous question |
|
|
319
|
+
|
|
320
|
+
On the free-text row the arrows are the text cursor's first, and only act on
|
|
321
|
+
the step at the ends of what you have typed. `enter` always commits.
|
|
162
322
|
|
|
163
323
|
## Custom Configuration
|
|
164
324
|
|
|
@@ -192,6 +352,10 @@ On native Windows, `app.suspend` has no default binding because Windows terminal
|
|
|
192
352
|
}
|
|
193
353
|
```
|
|
194
354
|
|
|
355
|
+
`ctrl+p` and `ctrl+n` are both free of app bindings by default — the model and
|
|
356
|
+
task-ledger dials that used to hold them are on `alt+m` and `alt+l` now — so
|
|
357
|
+
this config collides with nothing.
|
|
358
|
+
|
|
195
359
|
### Vim Example
|
|
196
360
|
|
|
197
361
|
```json
|
|
@@ -204,3 +368,6 @@ On native Windows, `app.suspend` has no default binding because Windows terminal
|
|
|
204
368
|
"tui.editor.cursorWordRight": ["alt+right", "alt+w"]
|
|
205
369
|
}
|
|
206
370
|
```
|
|
371
|
+
|
|
372
|
+
This one takes `alt+h` from the session picker, `alt+k` from the shortcut list
|
|
373
|
+
and `alt+w` from `/cd`; rebind those too if you use them.
|
package/docs/terminal-setup.md
CHANGED
|
@@ -8,13 +8,18 @@ Work out of the box.
|
|
|
8
8
|
|
|
9
9
|
## Alt keys
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
43 actions are reachable only with `Alt` — five of the six dials (`Alt+A` mode,
|
|
12
|
+
`Alt+M` model, `Alt+O` output, `Alt+L` task ledger, `Alt+C` color), settings
|
|
12
13
|
(`Alt+S`), the shortcut list (`Alt+K`), every picker verb, and the tree filters.
|
|
13
14
|
A terminal that speaks the Kitty keyboard protocol reports `Alt` correctly and
|
|
14
15
|
needs nothing here.
|
|
15
16
|
|
|
16
|
-
Interrupt (`Esc`), clear (`Ctrl+C`), exit (`Ctrl+D`)
|
|
17
|
-
deliberately never on `Alt`, so a misconfigured terminal is
|
|
17
|
+
Interrupt (`Esc`), clear (`Ctrl+C`), exit (`Ctrl+D`), expand (`Ctrl+O`) and
|
|
18
|
+
submit (`Enter`) are deliberately never on `Alt`, so a misconfigured terminal is
|
|
19
|
+
recoverable. So is every dial that changes what the agent does: the thinking
|
|
20
|
+
level also steps on `Shift+Tab`, and `/mode`, `/model` and `/color` reach the
|
|
21
|
+
rest. Only the two that change what is drawn — the tool output dial and the task
|
|
22
|
+
ledger's lens — need `Alt`, and `Ctrl+O` still reaches full output and back.
|
|
18
23
|
|
|
19
24
|
### macOS
|
|
20
25
|
|