Compare commits

...

5 Commits

Author SHA1 Message Date
Kit Langton fb5cd98dc7 fix(tui): restore transcript page keys 2026-08-06 16:42:58 -04:00
Kit Langton f471e51b95 fix(tui): navigate unread sessions with page keys 2026-08-06 16:38:17 -04:00
Kit Langton e5436ab5c1 refactor(tui): simplify session model drafts 2026-08-06 16:38:13 -04:00
Kit Langton d4216c5d9a fix(tui): keep model selection session scoped 2026-08-06 16:20:37 -04:00
Kit Langton c71b5d73d8 fix(tui): scope model selection to active location 2026-08-06 15:27:51 -04:00
4 changed files with 182 additions and 84 deletions
+6 -2
View File
@@ -8,17 +8,21 @@ import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useData } from "../context/data"
import { modelPreferenceKey } from "../model-preference"
import { useLocation } from "../context/location"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const data = useData()
const dialog = useDialog()
const location = useLocation()
const [query, setQuery] = createSignal("")
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
const connected = useConnected()
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
const models = createMemo(() => data.location.model.list() ?? [])
const providers = createMemo(
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
)
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
const showExtra = createMemo(() => connected() && !props.providerID)
+43 -44
View File
@@ -327,10 +327,6 @@ export function Prompt(props: PromptProps) {
if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id)
if (session.model) {
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
local.model.variant.set(session.model.variant)
}
syncedSessionID = sessionID
})
@@ -943,6 +939,25 @@ export function Prompt(props: PromptProps) {
await slash.command.run(slash.input)
return true
}
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
const slashHead = parseSlashHead(inputText, /\s/)
const isSkill =
slashHead !== undefined &&
(data.location.skill.list(currentLocation.ref) ?? []).some(
(skill) => skill.slash === true && skill.id === slashHead.name,
)
const isCommand =
slashHead !== undefined &&
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
const agent = local.agent.current()
if (!agent) return false
const selectedModel = local.model.current()
@@ -950,6 +965,15 @@ export function Prompt(props: PromptProps) {
void promptModelWarning()
return false
}
const usesModel = !props.sessionID || (store.mode !== "shell" && !isSkill)
if (usesModel && !local.model.available(selectedModel)) {
toast.show({
title: "Model unavailable",
message: `${selectedModel.providerID}/${selectedModel.modelID} is not available in this session's location`,
variant: "warning",
})
return false
}
const variant = local.model.variant.current()
let sessionID = props.sessionID
@@ -990,17 +1014,6 @@ export function Prompt(props: PromptProps) {
session = created
}
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
// Capture mode before it gets reset
const currentMode = store.mode
const editorSelection = editorContext()
@@ -1013,43 +1026,30 @@ export function Prompt(props: PromptProps) {
command: inputText,
})
setStore("mode", "normal")
} else if (
inputText.startsWith("/") &&
(data.location.command.list(currentLocation.current) ?? []).some(
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
} else if (slashHead && isCommand) {
move.startSubmit()
// Parse command from first line, preserve multi-line content in arguments
const firstLineEnd = inputText.indexOf("\n")
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
const [command, ...firstLineArgs] = firstLine.split(" ")
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
const model = { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }
const cancelCommit = local.model.expectCommit(sessionID, model)
void client.api.session
.command({
sessionID,
command: command.slice(1),
arguments: args,
command: slashHead.name,
arguments: slashHead.arguments,
agent: agent.id,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
model,
files: store.prompt.files,
agents: store.prompt.agents,
})
.catch((error) => {
cancelCommit()
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
})
} else if (
inputText.startsWith("/") &&
(data.location.skill.list(currentLocation.current) ?? []).some(
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
} else if (isSkill) {
move.startSubmit()
void client.api.session.skill({
sessionID,
skill: inputText.split("\n")[0].split(" ")[0].slice(1),
skill: slashHead!.name,
})
} else {
move.startSubmit()
@@ -1065,9 +1065,11 @@ export function Prompt(props: PromptProps) {
session.model.id !== selectedModel.modelID ||
(session.model.variant ?? "default") !== (variant ?? "default")
) {
await client.api.session.switchModel({
sessionID,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
const model = { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }
const cancelCommit = local.model.expectCommit(sessionID, model)
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
cancelCommit()
throw error
})
}
if (session?.revert) {
@@ -1320,10 +1322,7 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})()
if (!value) return undefined
const width =
dimensions().width < 44
? dimensions().width - 5
: Math.min(75, dimensions().width - 4) - 5
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
})
const locationLabel = createMemo(() => {
+132 -37
View File
@@ -1,7 +1,7 @@
import { createStore } from "solid-js/store"
import { dedupeWith } from "effect/Array"
import { createSimpleContext } from "./helper"
import { batch, createMemo } from "solid-js"
import { batch, createMemo, onCleanup } from "solid-js"
import { useEvent } from "./event"
import path from "path"
import { useTuiPaths } from "./runtime"
@@ -22,6 +22,7 @@ import { useToast } from "../ui/toast"
import { useRoute } from "./route"
import { useData } from "./data"
import { usePermission } from "./permission"
import { useLocation } from "./location"
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
@@ -57,26 +58,29 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const args = useArgs()
const event = useEvent()
const permission = usePermission()
const location = useLocation()
const models = () => data.location.model.list(location.ref)
const providers = () => data.location.provider.list(location.ref)
function isModelValid(model: ModelPreferenceModel) {
return !!data.location.model
.list()
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
}
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
for (const modelFn of modelFns) {
const model = modelFn()
if (!model) continue
if (isModelValid(model)) return model
if (model && isModelValid(model)) return model
}
}
function createAgent() {
const agents = createMemo(() =>
(data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
(data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
)
const visibleAgents = createMemo(() =>
(data.location.agent.list(location.ref) ?? []).filter((agent) => !agent.hidden),
)
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined,
})
@@ -128,20 +132,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = createAgent()
function createModel() {
type Selection = ModelPreferenceModel & { variant?: string }
const [modelStore, setModelStore] = createStore<
ModelPreference & {
ready: boolean
model: Record<string, ModelPreferenceModel>
defaults: Record<string, ModelPreferenceModel | undefined>
drafts: Record<string, Selection | undefined>
}
>({
ready: false,
model: {},
defaults: {},
drafts: {},
recent: [],
favorite: [],
variant: {},
})
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
const pendingCommits = new Map<string, string>()
const commitKey = (value: ModelPreferenceModel & { variant?: string }) =>
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
const state = {
pending: false,
}
@@ -191,7 +201,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const model = data.location.model.list()?.[0]
const model = models()?.[0]
if (!model) return undefined
return {
providerID: model.providerID,
@@ -200,21 +210,109 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const currentModel = createMemo(() => {
const sessionID = route.data.type === "session" ? route.data.sessionID : undefined
if (sessionID) return modelStore.drafts[sessionID] ?? durableSelection(sessionID)
const a = agent.current()
return (
getFirstValidModel(
() => a && modelStore.model[a.id],
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
fallbackModel,
) ?? undefined
const fallback = getFirstValidModel(
() => a && modelStore.defaults[agentModelKey(a.id)],
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
fallbackModel,
)
return fallback
})
function agentModelKey(agentID: string) {
const ref = location.ref ?? data.location.default()
return `agent:${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
}
function durableSelection(sessionID: string): Selection | undefined {
const model = data.session.get(sessionID)?.model
if (!model) return
return {
providerID: model.providerID,
modelID: model.id,
variant: normalizeModelVariant(model.variant),
}
}
function setDraft(sessionID: string, selection: Selection) {
const durable = durableSelection(sessionID)
setModelStore(
"drafts",
sessionID,
durable && commitKey(durable) === commitKey(selection) ? undefined : selection,
)
}
function select(model: ModelPreferenceModel) {
if (route.data.type === "session") {
const sessionID = route.data.sessionID
const current = modelStore.drafts[sessionID] ?? durableSelection(sessionID)
const preferred = normalizeModelVariant(
current?.providerID === model.providerID && current.modelID === model.modelID
? current.variant
: modelStore.variant[modelPreferenceKey(model)],
)
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
setDraft(sessionID, { ...model, variant })
return true
}
const current = agent.current()
if (!current) return false
setModelStore("defaults", agentModelKey(current.id), model)
return true
}
onCleanup(
event.on("session.model.selected", (evt) => {
const expected = pendingCommits.get(evt.data.sessionID)
if (!expected) return
pendingCommits.delete(evt.data.sessionID)
const committed = commitKey({
providerID: evt.data.model.providerID,
modelID: evt.data.model.id,
variant: evt.data.model.variant,
})
if (committed !== expected) return
const draft = modelStore.drafts[evt.data.sessionID]
if (draft && commitKey(draft) === committed) setModelStore("drafts", evt.data.sessionID, undefined)
}),
)
onCleanup(
event.on("session.deleted", (evt) => {
pendingCommits.delete(evt.data.sessionID)
setModelStore("drafts", evt.data.sessionID, undefined)
}),
)
return {
current: currentModel,
available(model = currentModel()) {
return model ? isModelValid(model) : false
},
expectCommit(
sessionID: string,
value: {
providerID: string
id: string
variant?: string
},
) {
const committed = commitKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
pendingCommits.set(sessionID, committed)
return () => {
if (pendingCommits.get(sessionID) === committed) pendingCommits.delete(sessionID)
}
},
get ready() {
return modelStore.ready
},
get catalogReady() {
return models() !== undefined
},
recent() {
return modelStore.recent
},
@@ -230,30 +328,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
reasoning: false,
}
}
const provider = data.location.provider.list()?.find((item) => item.id === value.providerID)
const info = data.location.model
.list()
?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
const provider = providers()?.find((item) => item.id === value.providerID)
const info = models()?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
return {
provider: provider?.name ?? value.providerID,
model: info?.name ?? value.modelID,
model: info?.name ?? `${value.modelID} (unavailable)`,
reasoning: (info?.variants?.length ?? 0) !== 0,
}
}),
cycle(direction: 1 | -1) {
const current = currentModel()
if (!current) return
const recent = modelStore.recent
const recent = recentModels(current, modelStore.recent).filter(isModelValid)
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
if (index === -1) return
let next = index + direction
let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction
if (next < 0) next = recent.length - 1
if (next >= recent.length) next = 0
const val = recent[next]
if (!val) return
const a = agent.current()
if (!a) return
setModelStore("model", a.id, { ...val })
select({ ...val })
},
cycleFavorite(direction: 1 | -1) {
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
@@ -279,18 +372,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const next = favorites[index]
if (!next) return
const a = agent.current()
if (!a) return
setModelStore("model", a.id, { ...next })
if (!select({ ...next })) return
setModelStore("recent", recentModels(next, modelStore.recent))
save()
},
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => {
if (!isModelValid(model)) return
const a = agent.current()
if (!a) return
setModelStore("model", a.id, model)
if (!select(model)) return
if (options?.recent) {
setModelStore("recent", recentModels(model, modelStore.recent))
save()
@@ -317,6 +406,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
selected() {
const m = currentModel()
if (!m) return undefined
if (route.data.type === "session") {
const selection = modelStore.drafts[route.data.sessionID] ?? durableSelection(route.data.sessionID)
if (selection?.providerID === m.providerID && selection.modelID === m.modelID) return selection.variant
}
return normalizeModelVariant(modelStore.variant[modelPreferenceKey(m)])
},
current() {
@@ -327,14 +420,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
list() {
const m = currentModel()
if (!m) return []
const info = data.location.model
.list()
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
const info = models()?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
return info?.variants?.map((variant) => variant.id) ?? []
},
set(value: string | undefined) {
const m = currentModel()
if (!m) return
if (route.data.type === "session") {
setDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
return
}
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
save()
},
+1 -1
View File
@@ -361,7 +361,7 @@ export function Session() {
createEffect(() => {
const current = prompt()
if (sent || !current || !synced() || !local.model.ready) return
if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return
if (!local.agent.current() || !local.model.current()) return
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
sent = true