Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions docs/docs/configure/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,25 +201,26 @@ altimate-code skill publish my-tool # upload every file in the skill dir

### TUI

Open the skill browser with `ctrl+i` when no other dialog is open, or type `/skills` in the prompt:
Open the skill browser by typing `/skills` in the prompt (or `<leader>k`):

![Skill Browser](../assets/images/skills/tui-skill-browser.png)

**Keyboard shortcuts:**

| Key | Action |
|-----|--------|
| `ctrl+i` | Open skill browser (when no dialog is open) / Install skill (when inside browser) |
| Enter | Use — inserts `/<skill-name>` into the prompt |
| `ctrl+a` | Actions — show, edit, test, remove, or publish the selected skill to the linked workspace (the publish row appears only with `ALTIMATE_WORKSPACE=1`) |
| `ctrl+n` | New — scaffold a new skill + CLI tool |
| `ctrl+e` | New — scaffold a new skill + CLI tool (`ctrl+n` moves down the list, as in every dialog) |
| `ctrl+g` | Install a skill from a GitHub repo, URL, or local path (`ctrl+i` is Tab in most terminals, so it cannot be the chord) |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The Install binding moved to ctrl+g here, but docs/docs/configure/tools/custom.md:76 still tells users to press ctrl+i in the TUI skill browser, and ctrl+i is effectively Tab (byte 0x09), same reason this line gives. Users who follow custom.md get Tab behavior instead of Install. Update the custom.md reference to ctrl+g to match.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/docs/configure/skills.md, line 215:

<comment>The Install binding moved to `ctrl+g` here, but `docs/docs/configure/tools/custom.md:76` still tells users to press `ctrl+i` in the TUI skill browser, and `ctrl+i` is effectively Tab (byte 0x09), same reason this line gives. Users who follow custom.md get Tab behavior instead of Install. Update the custom.md reference to `ctrl+g` to match.</comment>

<file context>
@@ -212,15 +212,15 @@ Open the skill browser by typing `/skills` in the prompt (or `<leader>k`):
 | `ctrl+a` | Actions — show, edit, test, remove, or publish the selected skill to the linked workspace (the publish row appears only with `ALTIMATE_WORKSPACE=1`) |
 | `ctrl+e` | New — scaffold a new skill + CLI tool (`ctrl+n` moves down the list, as in every dialog) |
-| `ctrl+i` | Install a skill from a GitHub repo, URL, or local path |
+| `ctrl+g` | Install a skill from a GitHub repo, URL, or local path (`ctrl+i` is Tab in most terminals, so it cannot be the chord) |
 | Tab / Shift+Tab | Move between the **Actions · New · Install** buttons in the footer, then Enter — the same three without a chord |
 | Esc | Back — returns to previous screen |
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 995bb3b.

| Tab / Shift+Tab | Move between the **Actions · New · Install** buttons in the footer, then Enter — the same three without a chord |
| Esc | Back — returns to previous screen |

**Create skill** (`ctrl+n`):
**Create skill** (`ctrl+e`, or the **New** footer button):

![Create Skill Dialog](../assets/images/skills/tui-skill-create.png)

**Install skill** (`ctrl+i` inside browser):
**Install skill** (`ctrl+g`, or the **Install** footer button):

![Install Skill Dialog](../assets/images/skills/tui-skill-install.png)

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/configure/tools/custom.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ altimate-code skill install https://github.com/owner/repo/tree/main/skills/my-sk
altimate-code skill remove my-skill
```

Or use the TUI: type `/skills`, then `ctrl+i` to install or `ctrl+a` → Remove to delete.
Or use the TUI: type `/skills`, then `ctrl+g` (or the **Install** footer button) to install, or `ctrl+a` → Remove to delete.

### Output Conventions

Expand Down
58 changes: 56 additions & 2 deletions packages/opencode/src/plugin/tui/altimate/skill-ops.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -781,9 +781,61 @@ function DialogSkillList(props: { api: TuiPluginApi; onCurrent: (skill: string |
}
// altimate_change end
props.onCurrent(item.value)
// Selecting a skill opens its action picker (the pre-merge default action was the picker).
// altimate_change start — Enter USES the skill: it inserts `/<skill> ` into the
// prompt, as the docs say and as the core selector this dialog now replaces did
// (#1328). The action picker moved to ctrl+a and the "Actions" footer button.
const ref = api.prompt.active()
if (ref) {
ref.set({ ...ref.current, input: `/${item.value} `, parts: [] })
api.ui.dialog.clear()
ref.focus()
return
}
// No prompt to write into (no session mounted): fall back to the picker.
openActionPicker(api, skillMap().get(item.value), item.value, () => showList(api))
// altimate_change end
}}
// altimate_change start — the picker, create and install as DIALOG actions (#1328).
// The plugin's global keymap layer registers ctrl+a / ctrl+n / ctrl+i too, but while
// this dialog is open its own layer outranks that one: ctrl+a went to the filter
// input's line-home and ctrl+n to `dialog.select.next`. Declared here they are bound
// inside the dialog (the model dialog binds ctrl+a the same way) and rendered as
// footer buttons reachable with Tab, so the picker no longer depends on a chord at
// all. ctrl+n stays the dialog's own "next"; New is ctrl+e in here. Install is
// ctrl+g, not ctrl+i: most terminals send ctrl+i as byte 0x09, which is Tab — the
// footer's own key (bot review).
actions={[
{
command: "altimate.skill.list.actions",
title: "Actions",
disabled: (option) => option === undefined || option.value === INSTALL_ACTION_VALUE,
onTrigger: (item) => {
if (!item || item.value === INSTALL_ACTION_VALUE) return
props.onCurrent(item.value)
openActionPicker(api, skillMap().get(item.value), item.value, () => showList(api))
},
},
// New and Install need no highlighted row: typing a name that matches no
// installed skill and pressing ctrl+e is the create-from-filter flow.
{
command: "altimate.skill.list.create",
title: "New",
standalone: true,
onTrigger: () => showCreate(api, filter().trim() || undefined),
},
{
command: "altimate.skill.list.install",
title: "Install",
standalone: true,
onTrigger: () => showInstall(api, filter().trim() || undefined),
},
]}
bindings={[
{ key: "ctrl+a", cmd: "altimate.skill.list.actions" },
{ key: "ctrl+e", cmd: "altimate.skill.list.create" },
{ key: "ctrl+g", cmd: "altimate.skill.list.install" },
]}
// altimate_change end
/>
)
}
Expand Down Expand Up @@ -884,11 +936,13 @@ const tui: TuiPlugin = async (api) => {
// ctrl+a -> actions · ctrl+n -> create · ctrl+i -> install.
// altimate_change start — restore a default key to OPEN the skills list (pre-merge skill_list
// was ctrl+i, which now collides with tab/agent-cycle; use a collision-free <leader>k instead).
// Install has no global chord: ctrl+i is Tab on the wire for most terminals, and
// ctrl+g is the session route's "first message". Inside the browser it is ctrl+g
// (a dialog-local binding, see DialogSkillList); from anywhere else, the palette.
bindings: [
{ key: "<leader>k", cmd: "altimate.skill.list" },
{ key: "ctrl+a", cmd: "altimate.skill.actions" },
{ key: "ctrl+n", cmd: "altimate.skill.create" },
{ key: "ctrl+i", cmd: "altimate.skill.install" },
],
// altimate_change end
})
Expand Down
23 changes: 23 additions & 0 deletions packages/plugin/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,32 @@ export type TuiDialogSelectProps<Value = unknown> = {
// altimate_change start — a fixed-option dialog can hide the filter box entirely
renderFilter?: boolean
// altimate_change end
// altimate_change start — dialog-level actions: footer buttons (Tab-reachable) with
// keybinds that are live INSIDE the dialog, where the dialog's own layer outranks a
// plugin's global keymap layer. Mirrors the host DialogSelect `actions`/`bindings`.
actions?: TuiDialogSelectAction<Value>[]
bindings?: { key: string; cmd: string }[]
// altimate_change end
current?: Value
}

// altimate_change start
export type TuiDialogSelectAction<Value = unknown> = {
/** Command name the `bindings` entries refer to. */
command: string
title: string
side?: "left" | "right"
hidden?: boolean
disabled?: boolean | ((option: TuiDialogSelectOption<Value> | undefined) => boolean)
/** Called with the highlighted option — or with `undefined` when `standalone` is set
* and no row is highlighted (empty list, nothing matches the filter). */
onTrigger: (option: TuiDialogSelectOption<Value> | undefined) => void
/** The action needs no highlighted row (create, install): it fires even when the
* list is empty or the filter matches nothing. */
standalone?: boolean
}
// altimate_change end

export type TuiPromptInfo = {
input: string
mode?: "normal" | "shell"
Expand Down
15 changes: 14 additions & 1 deletion packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -593,8 +593,21 @@ export function Prompt(props: PromptProps) {
title: "Skills",
name: "prompt.skills",
category: "Prompt",
slashName: "skills",
// altimate_change start — `/skills` belongs to the Altimate skills browser
// (`altimate.skill.list`: browse, actions, create, install). This command kept the
// same slash name, so autocomplete listed two `/skills` rows and Enter took this
// one — the plain selector with no actions — which is why ctrl+a never opened the
// picker (#1328). It has no slash name now, and its other two entry points — the
// palette row and a configured `prompt_skills` keybind — hand over to the browser
// when it is registered, so no route lands on the plain selector while a better
// one exists. Hidden from the palette then, too: two "Skills" rows invite the
// wrong one.
get hidden() {
return keymap.getCommands({ visibility: "registered", filter: { name: "altimate.skill.list" } }).length > 0
},
run: () => {
if (keymap.dispatchCommand("altimate.skill.list").ok) return
// altimate_change end
dialog.replace(() => (
<DialogSkill
onSelect={(skill) => {
Expand Down
32 changes: 30 additions & 2 deletions packages/tui/src/plugin/adapters.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { TuiDialogSelectOption, TuiPluginApi, TuiPromptRef, TuiSlotProps } from "@opencode-ai/plugin/tui"
// altimate_change start — TuiDialogSelectProps for the generic DialogSelect adapter
import type { TuiDialogSelectOption, TuiDialogSelectProps, TuiPluginApi, TuiPromptRef, TuiSlotProps } from "@opencode-ai/plugin/tui"
// altimate_change end
import type { TuiConfig } from "../config"
import type { useEvent } from "../context/event"
import type { usePromptRef } from "../context/prompt"
Expand Down Expand Up @@ -234,7 +236,10 @@ export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycl
DialogPrompt(props) {
return <DialogPrompt {...props} description={props.description} />
},
DialogSelect(props) {
// altimate_change start — generic over the option value so the dialog-level
// actions below can be typed against it
DialogSelect<Value>(props: TuiDialogSelectProps<Value>) {
// altimate_change end
return (
<DialogSelect
title={props.title}
Expand All @@ -248,6 +253,29 @@ export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycl
// altimate_change start — pass the filter-box switch through to the component
renderFilter={props.renderFilter}
// altimate_change end
// altimate_change start — dialog-level actions and their in-dialog keybinds
actions={props.actions?.map((action) => ({
command: action.command,
title: action.title,
side: action.side,
hidden: action.hidden,
disabled:
typeof action.disabled === "function"
? (option: SelectOption<Value> | undefined) =>
(action.disabled as (o: TuiDialogSelectOption<Value> | undefined) => boolean)(
option ? pickOption(option) : undefined,
)
: action.disabled,
standalone: true as const,
onTrigger: (option: SelectOption<Value> | undefined) => {
// The plugin API's shape is the row-bound one unless `standalone`; the
// core gate is applied here so a plugin action without a row is not called.
if (!option && !action.standalone) return
action.onTrigger(option ? pickOption(option) : undefined)
},
}))}
bindings={props.bindings}
// altimate_change end
current={props.current}
/>
)
Expand Down
49 changes: 34 additions & 15 deletions packages/tui/src/ui/dialog-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ import { getScrollAcceleration } from "../util/scroll"
import { useTuiConfig } from "../config"
import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"

// altimate_change start — see `actions`
type DialogSelectActionBase<T> = {
command: string
title: string
side?: "left" | "right"
hidden?: boolean
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
}
export type DialogSelectAction<T> =
| (DialogSelectActionBase<T> & { standalone?: false; onTrigger: (option: DialogSelectOption<T>) => void })
| (DialogSelectActionBase<T> & { standalone: true; onTrigger: (option: DialogSelectOption<T> | undefined) => void })
// altimate_change end

export interface DialogSelectProps<T> {
title: string
titleView?: JSX.Element
Expand All @@ -35,14 +48,12 @@ export interface DialogSelectProps<T> {
skipFilter?: boolean
renderFilter?: boolean
locked?: boolean
actions?: {
command: string
title: string
side?: "left" | "right"
hidden?: boolean
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
onTrigger: (option: DialogSelectOption<T>) => void
}[]
// altimate_change start — a `standalone` action needs no highlighted row (create,
// install): it fires with `undefined` when the list is empty or nothing matches the
// filter. The default keeps the row-bound contract every existing caller relies on,
// as a discriminated union so those callers' `onTrigger` still types as row-bound.
actions?: DialogSelectAction<T>[]
// altimate_change end
footerHints?: {
title: string
label: string
Expand Down Expand Up @@ -135,11 +146,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
.filter((item) => item.label),
...(props.footerHints ?? []),
])
const actionItems = createMemo(() =>
// altimate_change start — evaluated lazily rather than as an eager memo: `isActionDisabled`
// reads `selected()`, which is declared further down, so a function-valued `disabled`
// (the Skills browser's, #1328) threw "Cannot access 'selected' before initialization"
// during setup. Every existing caller passed a boolean, which never touched `selected`.
const actionItems = () =>
visibleActions()
.filter(isActionItem)
.filter((item) => !isActionDisabled(item)),
)
.filter((item) => !isActionDisabled(item))
// altimate_change end

createEffect(() => {
const index = focusedAction()
Expand Down Expand Up @@ -371,8 +386,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (isActionDisabled(item)) return
setStore("input", "keyboard")
const option = selected()
if (!option) return
item.onTrigger(option)
// altimate_change start — see `standalone`
if (item.standalone) item.onTrigger(option)
else if (option) item.onTrigger(option)
// altimate_change end
},
})),
],
Expand Down Expand Up @@ -434,8 +451,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (!item || !isActionItem(item) || isActionDisabled(item)) return
setStore("input", "keyboard")
const option = selected()
if (!option) return
item.onTrigger(option)
// altimate_change start — see `standalone`
if (item.standalone) item.onTrigger(option)
else if (option) item.onTrigger(option)
// altimate_change end
}

function isActionItem(item: VisibleAction): item is Action & { label: string } {
Expand Down
Loading
Loading